Skip to content

Commit 2d5daee

Browse files
committed
fix(stack-encrypt)!: a target field's label is refused when it is not table and column
Review of #1095: a target field whose label had three or more segments passed se_plan_check and then failed on every value with TargetError::Column from the resolver. A Go struct with context=app/users and email,encrypt_into=TextEq lowers to the label app/users/email; stashgen asks the engine for its rules, so it wrote the code, and Encrypt, Decrypt and Fields.Email.Query all failed afterwards. Plan::new_with now refuses it beside the Extended check, naming the field (TargetError::Column), so the refusal reaches se_plan_check and the generator before any code is written. FieldPlan::with_target keeps its constructor bound of two or more segments, like every field constructor, and the module docs now say where each rule lives. Go's record.Plan.Validate refuses an encrypt_into field whose context has more than one segment or any extension, and stashgen's lowerDeclaration names the field for the same, so the host says it first. Tests: the three-segment plan refused with the field and label named and nothing minted, the same label accepted for a sealed field; Go Validate refusals and the plain-field acceptance; the stashgen command refusing context=app/users by field with nothing written. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
1 parent 916b8c1 commit 2d5daee

5 files changed

Lines changed: 104 additions & 8 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,20 @@ func TestRunAsksTheEmbeddedEngine(t *testing.T) {
155155
if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) {
156156
t.Fatal("a file was written after the engine refused")
157157
}
158+
// A context of two segments leaves an EQL field no column: refused by
159+
// name, before the engine is asked, and nothing written.
160+
deep := strings.Replace(userSource, "context=users", "context=app/users", 1)
161+
dir = writeModule(t, map[string]string{"model.go": deep})
162+
stderr.Reset()
163+
if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 1 {
164+
t.Fatalf("exit %d, want 1\n%s", code, stderr.String())
165+
}
166+
if !strings.Contains(stderr.String(), "User.Email") || !strings.Contains(stderr.String(), `"app/users" has 2 segments`) {
167+
t.Fatalf("stderr = %q", stderr.String())
168+
}
169+
if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) {
170+
t.Fatal("a file was written after the refusal")
171+
}
158172
// Separate columns, as before.
159173
columns := strings.Replace(userSource, "encrypt_into=TextEq", "encrypt,index=equality;match", 1)
160174
dir = writeModule(t, map[string]string{"model.go": columns})

‎languages/golang/internal/record/record.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,16 @@ func (p *Plan) Validate() error {
227227
if len(f.Outputs) != 0 {
228228
return fmt.Errorf("record: field %q names the EQL type %s and outputs; a field has one or the other", f.Name, f.Target)
229229
}
230+
// An EQL value is stored under a table and a column: the
231+
// context is the table, the identity the column. The engine
232+
// refuses anything else when the plan is built; saying it here
233+
// names the field before the guest is asked.
234+
if len(p.Context) != 1 {
235+
return fmt.Errorf("record: field %q names the EQL type %s, and an EQL column is a table and a column: the context %q has %d segments, not one", f.Name, f.Target, strings.Join(p.Context, "/"), len(p.Context))
236+
}
237+
if len(p.Extension) != 0 {
238+
return fmt.Errorf("record: field %q names the EQL type %s, and an EQL column is a table and a column: an extended context has no column", f.Name, f.Target)
239+
}
230240
continue
231241
}
232242
if len(f.Outputs) == 0 {

‎languages/golang/internal/record/record_test.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,22 @@ func TestTargetFieldWiresItsTypeInsteadOfOutputs(t *testing.T) {
105105
if err := both.Validate(); err == nil {
106106
t.Fatal("a field with a target and outputs was accepted")
107107
}
108+
// An EQL column is a table and a column: a two-segment context, or an
109+
// extension, leaves the target field no column, and the refusal names
110+
// the field — before the guest is asked.
111+
deep := &Plan{Context: []string{"app", "users"}, Fields: []Field{{Name: "email", Kind: String, Target: "TextEq"}}}
112+
if err := deep.Validate(); err == nil || !strings.Contains(err.Error(), `field "email"`) || !strings.Contains(err.Error(), `"app/users" has 2 segments`) {
113+
t.Fatalf("a target under app/users: %v", err)
114+
}
115+
extended := &Plan{Context: []string{"users"}, Extension: []any{uint64(7)}, Fields: []Field{{Name: "email", Kind: String, Target: "TextEq"}}}
116+
if err := extended.Validate(); err == nil || !strings.Contains(err.Error(), "extended context has no column") {
117+
t.Fatalf("an extended target: %v", err)
118+
}
119+
// The same context seals a plain field as before.
120+
plain := &Plan{Context: []string{"app", "users"}, Fields: []Field{{Name: "email", Kind: String, Outputs: []Output{Ciphertext}}}}
121+
if err := plain.Validate(); err != nil {
122+
t.Fatal(err)
123+
}
108124
}
109125

110126
func TestValidateRefusals(t *testing.T) {

‎languages/golang/stashgen/engine.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ func lowerDeclaration(d Declaration, eqlTypes []EQLType) (*record.Plan, error) {
158158
if eqlType.Plaintext != KindOther && eqlType.Plaintext != f.GoType.Kind {
159159
return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s seals a %s, and %s is %s", f.EQLType, eqlType.Plaintext, f.GoType, f.GoType.Kind)}
160160
}
161+
// An EQL value is stored under a table and a column: the struct's
162+
// context is the table and the field's name the column, so a
163+
// context of two or more segments leaves the field no column.
164+
// The engine refuses it too (se_plan_check); naming it here
165+
// names the field.
166+
if len(segments) != 1 {
167+
return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s is stored under a table and a column, and the context %q has %d segments, not one", f.EQLType, d.Context, len(segments))}
168+
}
161169
rf.Target = f.EQLType
162170
case VerbEncrypt, VerbEncryptIndex:
163171
rf.Outputs = append(rf.Outputs, record.Ciphertext)

‎packages/stack-encrypt/src/dynamic/record.rs‎

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@
4747
//! that parses there has no target field.
4848
//!
4949
//! A target field's label must be a column, `<table>/<column>`, because
50-
//! that is what an EQL value stores in its `i`; an extended plan (a tenant
51-
//! part on every label) has no column for it and is refused with
50+
//! that is what an EQL value stores in its `i`: [`Plan::new_with`] refuses
51+
//! a target field whose label has any other number of segments
52+
//! ([`TargetError::Column`]) — [`FieldPlan::with_target`] itself takes any
53+
//! label of two or more segments, as every field constructor does; the
54+
//! column rule is the plan's — and an extended plan (a tenant part on every
55+
//! label) has no column for it and is refused with
5256
//! [`TargetError::Extended`] rather than silently dropping the extension.
5357
//! The field's `"type"`, when declared, must be the kind the EQL type is
5458
//! produced from ([`TargetError::Kind`]); undeclared, it is that kind, so
@@ -550,6 +554,19 @@ impl Plan {
550554
}
551555
.into());
552556
}
557+
// An EQL value is stored under a table and a column, so the
558+
// label is exactly two segments. Decided here, where the plan is
559+
// built and se_plan_check reports it, not at the first value: a
560+
// generator that asks the engine must be told before it writes
561+
// the code. The resolver re-checks, since it is public API.
562+
if field.label.segments().len() != 2 {
563+
return Err(TargetError::Column {
564+
name: field.name.clone(),
565+
label: field.label.to_string(),
566+
reason: "an EQL column is a two-segment label: table and column".to_owned(),
567+
}
568+
.into());
569+
}
553570
match (field.field_type, descriptor.plaintext) {
554571
(Some(declared), expected) if expected != Some(declared) => {
555572
return Err(TargetError::Kind {
@@ -4341,12 +4358,12 @@ mod tests {
43414358
}
43424359

43434360
/// The constructor's one bound on the label is "at least two
4344-
/// segments": a column is table and column, and a longer label is
4345-
/// still a label — whether a resolver can store under it is the
4346-
/// resolver's to say, and the extension rule is `Plan::new_with`'s.
4347-
/// Pinned from both sides so the bound cannot drift to "exactly two"
4348-
/// or flip: one segment refused (it is no label), two accepted, and
4349-
/// three — the first length above the bound — accepted intact.
4361+
/// segments", the same as every field constructor's: a longer label
4362+
/// is still a label. The column rule — exactly two — is
4363+
/// `Plan::new_with`'s, where the plan is built (the test after this
4364+
/// one). Pinned from both sides so the constructor's bound cannot
4365+
/// drift to "exactly two" or flip: one segment refused (it is no
4366+
/// label), two accepted, and three accepted intact.
43504367
#[test]
43514368
fn with_target_takes_any_label_of_two_or_more_segments() {
43524369
let ctx = |segments: &[&str]| context(strings(segments)).expect("a context value");
@@ -4368,6 +4385,37 @@ mod tests {
43684385
assert_eq!(three.target(), Some(TEXT_EQ));
43694386
}
43704387

4388+
/// `context=app/users` with `email,encrypt_into=TextEq` in Go gives
4389+
/// the label `app/users/email`: no column for it. Refused when the
4390+
/// plan is built, so `se_plan_check` tells the generator before it
4391+
/// writes the code, and no value ever reaches the resolver.
4392+
#[tokio::test]
4393+
async fn a_target_label_that_is_not_table_and_column_is_refused_when_the_plan_is_built() {
4394+
let cipher = cipher().await;
4395+
let value = obj(vec![(
4396+
"email",
4397+
target_spec(strings(&["app", "users", "email"]), TEXT_EQ),
4398+
)]);
4399+
let error = target_error(plan_with(value, &FakeEql).unwrap_err());
4400+
assert!(
4401+
matches!(&error, TargetError::Column { name, label, .. } if name == "email" && label == "app/users/email"),
4402+
"{error}"
4403+
);
4404+
assert_eq!(
4405+
error.to_string(),
4406+
"email: the label app/users/email is not an EQL column: an EQL column is a \
4407+
two-segment label: table and column"
4408+
);
4409+
// A sealed field under the same three-segment label is fine: the
4410+
// rule is the EQL column's, not the plan's.
4411+
let value = obj(vec![(
4412+
"email",
4413+
spec(strings(&["app", "users", "email"]), &["c"]),
4414+
)]);
4415+
assert!(plan_with(value, &FakeEql).is_ok());
4416+
assert_eq!(generates(&cipher), 0, "nothing minted");
4417+
}
4418+
43714419
#[test]
43724420
fn the_target_and_output_forms_are_exclusive() {
43734421
let both = obj(vec![

0 commit comments

Comments
 (0)