Skip to content

Commit d0d5799

Browse files
committed
refactor(golang): one DeterministicSource, protoc-gen-go's GoName, and the review's small findings
DeterministicSource existed twice — stack-encrypt's tests/common and the guest's deterministic.rs — and the Go fixture test depends on the two deriving the same bytes. It now lives once in stack-kms behind its test-support feature, beside FakeDataKeySource; the stack-encrypt tests and the guest import it, and the guest's deterministic-kms feature no longer carries its own sha2. The rebuilt test guest still opens the fixture, which is the proof the move changed no bytes. protosource.GoName is protoc-gen-go's GoCamelCase word for word: a digit ends a word (foo_1bar is Foo_1Bar, sha256sum is Sha256Sum), a leading underscore is X, a dot is an underscore unless a lower-case letter follows; TestGoName held the wrong value for foo_1bar. scalar's enum branch has a test through a dynamic descriptor, since testpb declares no enum. stack-guest-abi's crate doc names the guest's real path. The three documents say what a match index needs — text with at least one token, three characters for the n-gram — that the whole batch fails otherwise, and that an optional or short value does not belong under match. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
1 parent db87db5 commit d0d5799

12 files changed

Lines changed: 240 additions & 239 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ The first part of a tag is the field's name, which is the column name in a datab
7373
| `` _ struct{} `stash:"context=documents,opaque"` `` | seal the struct as one value |
7474

7575
The index names are `equality`, `match`, `ore`, `ope` and `json`.
76-
A `match` index needs text with at least one token: the engine derives no match term for an empty or separator-only string, or one shorter than the n-gram, because an empty term would match every row.
77-
`Encrypt` then returns an error naming the row, the field and the index; give the field a value, or drop the index.
76+
A `match` index needs text with at least one token: the engine derives no match term for an empty string, separator-only text, or text shorter than the n-gram length (3 characters), because an empty term would match every row.
77+
`Encrypt` then fails for the whole batch, with an error naming the row, the field and the index.
78+
So an optional or short value does not belong under `match`: give the field `equality` alone, or make the value required.
7879
An index takes its options in parentheses after its name, separated by commas: `index=equality;match(k=3)`.
7980
These words are the same as the Rust API's words for the same behaviour.
8081

‎languages/golang/encrypt/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ and `Equality`, `Match`, `Ore` or `Ope` (the term types). A field with
143143
An `opaque` struct is one `Sealed` field. Every stored type implements
144144
`driver.Valuer` and `sql.Scanner`.
145145

146+
A `match` index needs text with at least one token. The engine derives no
147+
match term for an empty string, separator-only text, or text shorter than the
148+
n-gram length (3 characters), because an empty term would match every row.
149+
`Encrypt` then fails for the whole batch with `ErrTerm`, naming the row, the
150+
field and the index. So an optional or short value does not belong under
151+
`match`: give such a field `equality` alone, or make the value required.
152+
146153
Terms are byte-equal to the ones the Rust crate derives, so a term from
147154
`users.Fields.Email.Equality` compares against a stored term written from any
148155
language. `EqualityTerm.Equal` compares in constant time; `OreTerm.Compare`

‎languages/golang/encrypt/doc.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@
9393
// [Match], [Ore] and [Ope]; [JSON] is declared and refused until the engine
9494
// derives it. Every stored type implements driver.Valuer and sql.Scanner.
9595
//
96+
// A match index needs text with at least one token: the engine derives no
97+
// match term for an empty string, separator-only text, or text shorter than
98+
// the n-gram length (3 characters), because an empty term would match every
99+
// row. Encrypt then fails for the whole batch with [ErrTerm], naming the row,
100+
// the field and the index, so an optional or short value does not belong
101+
// under match.
102+
//
96103
// # Transport and auth
97104
//
98105
// The guest imports exactly two host functions: an HTTP send, served by any

‎languages/golang/encrypt/guest/Cargo.lock‎

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

‎languages/golang/encrypt/guest/Cargo.toml‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ crate-type = ["cdylib", "rlib"]
3030
# network. The Go tests load it to open the fixture's records and to run
3131
# hermetic round trips; `mise run wasm:guest:build:deterministic` builds it
3232
# beside the real guest. Never the embedded guest.
33-
deterministic-kms = ["stack-kms/test-support", "dep:sha2"]
33+
deterministic-kms = ["stack-kms/test-support"]
3434

3535
[dependencies]
3636
# The stack crates with default features off: no reqwest, no native TLS —
@@ -56,8 +56,6 @@ vitaminc-aead-value = "0.5.1"
5656
vitaminc-protected = "0.5.1"
5757

5858
futures = { version = "0.3", default-features = false, features = ["executor"] }
59-
# Only the deterministic-kms test build derives keys itself.
60-
sha2 = { version = "0.10", optional = true }
6159
serde = "1"
6260
serde_json = "1"
6361
uuid = "1"
Lines changed: 11 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,13 @@
1-
//! The deterministic key source of the `deterministic-kms` test build: a
2-
//! copy of `DeterministicSource` in stack-encrypt's `tests/common/mod.rs`,
3-
//! which sealed the record fixture (`tests/fixtures/record_lowering.json`).
1+
//! The deterministic key source of the `deterministic-kms` test build:
2+
//! `stack-kms`'s [`DeterministicSource`] (behind its `test-support` feature),
3+
//! the one definition that also sealed stack-encrypt's record fixture
4+
//! (`tests/fixtures/record_lowering.json`). A Go test that loads this build
5+
//! with the fixture's seed opens the records Rust sealed and derives the same
6+
//! term bytes, because both run the same code.
47
//!
5-
//! Every data key is `SHA-256(seed ‖ "key" ‖ 0 ‖ descriptor ‖ 0 ‖ iv)`, its
6-
//! tag `SHA-256(seed ‖ "tag" ‖ 0 ‖ descriptor ‖ 0 ‖ iv)`, and the IV
7-
//! `SHA-256(seed ‖ "iv" ‖ 0 ‖ descriptor ‖ 0 ‖ counter)[..16]`, where
8-
//! `descriptor` is the context the leaf is sealed under as ZeroKMS renders
9-
//! it. A leaf opens under its own field's descriptor and no other, as under
10-
//! ZeroKMS. The index key is `FakeDataKeySource`'s, the same every test in
11-
//! the repository derives terms under. So a Go test that loads this build
12-
//! with the fixture's seed opens the records Rust sealed and derives the
13-
//! same term bytes.
14-
//!
15-
//! It is a test double. The feature that compiles it is off by default, the
16-
//! Go package never embeds this build, and the fixture README beside the
17-
//! JSON file is the one definition both copies follow.
18-
19-
use std::borrow::Cow;
20-
use std::sync::atomic::{AtomicU64, Ordering};
21-
22-
use sha2::{Digest, Sha256};
23-
use stack_kms::{
24-
DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy,
25-
IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext,
26-
};
27-
use uuid::Uuid;
28-
29-
/// The seeded source. See the [module docs](self).
30-
pub struct DeterministicSource {
31-
seed: [u8; 32],
32-
counter: AtomicU64,
33-
index: FakeDataKeySource,
34-
}
35-
36-
impl DeterministicSource {
37-
/// A source over `seed`.
38-
pub fn new(seed: [u8; 32]) -> Self {
39-
Self {
40-
seed,
41-
counter: AtomicU64::new(0),
42-
index: FakeDataKeySource::new(),
43-
}
44-
}
45-
46-
fn derive(&self, what: &str, descriptor: &str, salt: &[u8]) -> [u8; 32] {
47-
let mut hasher = Sha256::new();
48-
hasher.update(self.seed);
49-
hasher.update(what.as_bytes());
50-
hasher.update([0u8]);
51-
hasher.update(descriptor.as_bytes());
52-
hasher.update([0u8]);
53-
hasher.update(salt);
54-
hasher.finalize().into()
55-
}
56-
}
57-
58-
impl DataKeySource for DeterministicSource {
59-
async fn generate_keys(
60-
&self,
61-
payloads: Vec<GenerateKeyPayload<'_>>,
62-
_keyset_id: Option<Uuid>,
63-
_unverified_context: Option<Cow<'_, UnverifiedContext>>,
64-
) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> {
65-
Ok(payloads
66-
.into_iter()
67-
.map(|payload| {
68-
let n = self.counter.fetch_add(1, Ordering::SeqCst);
69-
let iv_bytes = self.derive("iv", payload.descriptor, &n.to_le_bytes());
70-
let mut iv = stack_kms::Iv::default();
71-
let width = iv.len();
72-
iv.copy_from_slice(&iv_bytes[..width]);
73-
let key = self.derive("key", payload.descriptor, &iv);
74-
let tag = self.derive("tag", payload.descriptor, &iv);
75-
DataKeyWithTag {
76-
key: DataKey { iv, key },
77-
tag: tag.to_vec(),
78-
decryption_policy: payload.decryption_policy,
79-
}
80-
})
81-
.collect())
82-
}
83-
84-
async fn retrieve_keys(
85-
&self,
86-
payloads: Vec<RetrieveKeyPayload<'_>>,
87-
_keyset_id: Option<Uuid>,
88-
_unverified_context: Option<&UnverifiedContext>,
89-
) -> Result<Vec<DataKey>, stack_kms::Error> {
90-
payloads
91-
.iter()
92-
.map(|payload| {
93-
let iv: stack_kms::Iv = *payload.iv.as_ref();
94-
let tag = self.derive("tag", payload.descriptor, &iv);
95-
if tag[..] != *payload.tag {
96-
return Err(stack_kms::Error::RetrieveKey(
97-
stack_kms::RetrieveKeyError::FailedRetrieval(
98-
"the tag is not this descriptor's".to_string(),
99-
),
100-
));
101-
}
102-
let key = self.derive("key", payload.descriptor, &iv);
103-
Ok(DataKey { iv, key })
104-
})
105-
.collect()
106-
}
107-
}
8+
//! It is a test double. The feature that compiles it is off by default, and
9+
//! the build it produces goes under languages/golang/encrypt/testdata, which
10+
//! `go build` and the package's `//go:embed wasm` both ignore, so no Go
11+
//! binary carries it; the tests read it from disk.
10812
109-
impl IndexKeySource for DeterministicSource {
110-
async fn load_index_key(
111-
&self,
112-
keyset_id: Option<IdentifiedBy>,
113-
) -> Result<(Uuid, IndexKey), stack_kms::Error> {
114-
self.index.load_index_key(keyset_id).await
115-
}
116-
}
13+
pub use stack_kms::DeterministicSource;

‎languages/golang/encrypt/policy/protosource/protosource.go‎

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ package protosource
88

99
import (
1010
"fmt"
11-
"strings"
1211

1312
"google.golang.org/protobuf/proto"
1413
"google.golang.org/protobuf/reflect/protoreflect"
@@ -82,27 +81,42 @@ func scalar(fd protoreflect.FieldDescriptor, v protoreflect.Value) string {
8281
return v.String()
8382
}
8483

85-
// GoName is the Go field name protoc-gen-go gives a proto field: the first
86-
// letter capitalised, and an underscore before a lower-case letter dropped
87-
// with that letter capitalised. medicare_no becomes MedicareNo; foo_1bar
88-
// keeps its underscore, as protoc-gen-go does.
84+
// GoName is the Go field name protoc-gen-go gives a proto field: its
85+
// GoCamelCase, word for word. A word starts at an underscore or a capital;
86+
// a digit is a word of its own, so the letter after it starts a new word
87+
// (foo_1bar is Foo_1Bar, sha256sum is Sha256Sum); an underscore before a
88+
// lower-case letter is dropped and the letter capitalised; a leading
89+
// underscore becomes X (_x is XX); a dot becomes an underscore.
8990
func GoName(protoName string) string {
90-
var b strings.Builder
91-
upper := true
91+
var b []byte
9292
for i := 0; i < len(protoName); i++ {
9393
c := protoName[i]
9494
switch {
95-
case c == '_' && i+1 < len(protoName) && protoName[i+1] >= 'a' && protoName[i+1] <= 'z':
96-
upper = true
95+
case c == '.' && i+1 < len(protoName) && isASCIILower(protoName[i+1]):
96+
// Skip over '.' in ".{{lowercase}}".
9797
case c == '.':
98-
b.WriteByte('_')
99-
case upper && c >= 'a' && c <= 'z':
100-
b.WriteByte(c - 'a' + 'A')
101-
upper = false
98+
b = append(b, '_')
99+
case c == '_' && (i == 0 || protoName[i-1] == '.'):
100+
// An initial '_' (or one after '.') becomes 'X', so the name
101+
// starts with a capital.
102+
b = append(b, 'X')
103+
case c == '_' && i+1 < len(protoName) && isASCIILower(protoName[i+1]):
104+
// Skip over '_' in "_{{lowercase}}".
105+
case c >= '0' && c <= '9':
106+
b = append(b, c)
102107
default:
103-
b.WriteByte(c)
104-
upper = false
108+
// A letter starts a word, capitalised; the lower-case run after
109+
// it is the rest of the word.
110+
if isASCIILower(c) {
111+
c -= 'a' - 'A'
112+
}
113+
b = append(b, c)
114+
for ; i+1 < len(protoName) && isASCIILower(protoName[i+1]); i++ {
115+
b = append(b, protoName[i+1])
116+
}
105117
}
106118
}
107-
return b.String()
119+
return string(b)
108120
}
121+
122+
func isASCIILower(c byte) bool { return 'a' <= c && c <= 'z' }

‎languages/golang/encrypt/policy/protosource/protosource_test.go‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import (
44
"strings"
55
"testing"
66

7+
"google.golang.org/protobuf/proto"
8+
"google.golang.org/protobuf/reflect/protodesc"
9+
"google.golang.org/protobuf/reflect/protoreflect"
10+
"google.golang.org/protobuf/types/descriptorpb"
11+
712
"github.com/cipherstash/stack/languages/golang/encrypt/policy"
813
"github.com/cipherstash/stack/languages/golang/encrypt/policy/protosource/internal/testpb"
914
)
@@ -62,10 +67,52 @@ func TestNotAMessage(t *testing.T) {
6267
}
6368

6469
func TestGoName(t *testing.T) {
65-
cases := map[string]string{"id": "Id", "medicare_no": "MedicareNo", "foo_1bar": "Foo_1bar", "Already": "Already", "a_b_c": "ABC", "x__y": "X_Y"}
70+
// protoc-gen-go's GoCamelCase, including the cases a reviewer found the
71+
// first version wrong on: a digit ends a word, a leading underscore is X.
72+
cases := map[string]string{
73+
"id": "Id", "medicare_no": "MedicareNo", "foo_1bar": "Foo_1Bar", "sha256sum": "Sha256Sum", "_x": "XX",
74+
"Already": "Already", "a_b_c": "ABC", "x__y": "X_Y", "a.b": "AB", "a.B": "A_B", "x_Y": "X_Y", "ab1": "Ab1",
75+
}
6676
for in, want := range cases {
6777
if got := GoName(in); got != want {
6878
t.Errorf("GoName(%q) = %q, want %q", in, got, want)
6979
}
7080
}
7181
}
82+
83+
// scalar spells an enum option by its value name: a dynamic enum field
84+
// stands in for a generated one, since testpb declares no enum.
85+
func TestScalarSpellsAnEnumByName(t *testing.T) {
86+
file, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{
87+
Name: proto.String("enum_test.proto"),
88+
Package: proto.String("enumtest"),
89+
Syntax: proto.String("proto3"),
90+
EnumType: []*descriptorpb.EnumDescriptorProto{{
91+
Name: proto.String("Level"),
92+
Value: []*descriptorpb.EnumValueDescriptorProto{
93+
{Name: proto.String("LEVEL_UNSPECIFIED"), Number: proto.Int32(0)},
94+
{Name: proto.String("LEVEL_HIGH"), Number: proto.Int32(2)},
95+
},
96+
}},
97+
MessageType: []*descriptorpb.DescriptorProto{{
98+
Name: proto.String("Holder"),
99+
Field: []*descriptorpb.FieldDescriptorProto{{
100+
Name: proto.String("level"), Number: proto.Int32(1),
101+
Type: descriptorpb.FieldDescriptorProto_TYPE_ENUM.Enum(),
102+
TypeName: proto.String(".enumtest.Level"),
103+
JsonName: proto.String("level"),
104+
}},
105+
}},
106+
}, nil)
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
fd := file.Messages().Get(0).Fields().Get(0)
111+
if got := scalar(fd, protoreflect.ValueOfEnum(2)); got != "LEVEL_HIGH" {
112+
t.Fatalf("scalar(enum 2) = %q, want LEVEL_HIGH", got)
113+
}
114+
// A number with no name falls back to the number.
115+
if got := scalar(fd, protoreflect.ValueOfEnum(7)); got != "7" {
116+
t.Fatalf("scalar(enum 7) = %q", got)
117+
}
118+
}

0 commit comments

Comments
 (0)