Skip to content

Commit faeee56

Browse files
authored
feat(stack-encrypt)!: plan grammar gains two starts, three context sources, encrypt_into and pickers
The derive is to emit the plan, so the plan must say everything the narrowed derive says. This adds, Rust-only: - Two starts: Plan::fields() and Plan::value::<S>(), each with an optional .context(c); Plan::context(c).fields()/.with(..) keep working. The FieldPlan iterator on a built plan is renamed field_plans(), since Plan::fields() is now the start. - Exactly one context source: the plan, the call (cipher.encrypt(&v).context(c).using(&plan), and .context(c) on query and open), or a context field of the value (.context_field(field)), carried as a passthrough and checked on open through ExpectedContext. Two is TwoContextSources at build or at the call; none is NoContext when the plan runs, before any key request. - encrypt_into: the field form lowers to the target's own EncryptFrom under <context>/<identity>, exactly as the derive composes a record-typed field; the one-value form takes a target or a tuple of them. A field is a target or data verbs, never both (TargetWithVerbs). A typed field answers the queries its target declares. - Pickers: every verb takes a name (Field<F> lookup) or a name with an accessor; a plan of pickers needs no Fields impl and skips the run-time field-name check. Also the #1071 review: a pinned identity rescues a field name that is not a plain segment (tuple fields "0", renamed columns); a Vec written through a one-value plan opens through it in one request; a query whose match options differ says so (IndexOptions); the over-strong "before any request" claims are scoped; the parameter and doc noun row is now record. Runs::check and Opens::check take the context the call names, so a chain refuses a missing or doubled context, and a context field's mismatch, before it loads a keyset. The plan_build fuzz target follows: it builds plans with or without a context and with context fields, and asserts that a context given twice is refused as TwoContextSources and that a contextless plan keys each sealed field under one plain identity segment. BREAKING CHANGE: the unreleased plan API changes shape (nothing here is in 0.2.0). Plan::fields() starts a fields plan, so a built plan's field iterator is Plan::field_plans(). A one-value plan is ValuePlan<S, Indexed<X>> or ValuePlan<S, Typed<T>> in place of ValuePlan<S, X>. Plan::label and ValuePlan::label return Option<&Label>. Runs::pending, Runs::check, Opens::decryption, Opens::check, Plan::encryption, Plan::decryption and ValuePlan::encryption take the context the call names. The field verbs take a FieldRef (a name still works).
1 parent 76484ef commit faeee56

17 files changed

Lines changed: 3443 additions & 558 deletions

‎packages/stack-encrypt/fuzz/fuzz_targets/plan_build.rs‎

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,24 @@
99
//! alphabet, so duplicates, shared identities and reserved forms collide
1010
//! often, or as free text.
1111
//!
12-
//! Three invariants. Building and rendering never panic, whatever the
13-
//! names. A plan that builds gives every sealed or indexed field a
14-
//! well-formed label: `<context>/<identity>`, which renders and parses back
15-
//! to itself. And a passthrough field is under no label, so a plan of
16-
//! passthrough fields with distinct names under a plain context builds,
17-
//! whatever text the names are.
12+
//! The plan may be built with its context, or without one (the call names
13+
//! it), and a field may be declared as the plan's context field, so a
14+
//! context can also be given twice.
15+
//!
16+
//! Four invariants. Building and rendering never panic, whatever the
17+
//! names. A plan built with its context gives every sealed or indexed field
18+
//! a well-formed label: `<context>/<identity>`, which renders and parses
19+
//! back to itself; a plan without one gives none, and keys each such field
20+
//! under a plain identity segment. A passthrough or context field is under
21+
//! no label, so a plan of passthrough fields with distinct names, under a
22+
//! plain context or none, builds whatever text the names are. And a plan
23+
//! whose context is given twice is refused with `TwoContextSources`.
1824
1925
use std::collections::HashSet;
2026

2127
use arbitrary::Arbitrary;
2228
use libfuzzer_sys::fuzz_target;
23-
use stack_encrypt::plan::{FieldKind, FieldValues};
29+
use stack_encrypt::plan::{FieldKind, FieldValues, PlanError};
2430
use stack_encrypt::{Equality, Error, Label, Plan};
2531

2632
/// A name: one of a few that collide or sit on a rule's edge, or free text.
@@ -58,6 +64,7 @@ enum Verb {
5864
EncryptIndex,
5965
Index,
6066
Passthrough,
67+
ContextField,
6168
}
6269

6370
#[derive(Arbitrary, Debug)]
@@ -69,50 +76,82 @@ struct Declared {
6976

7077
#[derive(Arbitrary, Debug)]
7178
struct Input {
72-
context: Name,
79+
/// `None`: a plan built without a context.
80+
context: Option<Name>,
7381
fields: Vec<Declared>,
7482
}
7583

7684
fuzz_target!(|input: Input| {
77-
let mut builder = Plan::context(input.context.as_str()).fields::<FieldValues, ()>();
85+
let mut builder = match &input.context {
86+
Some(context) => Plan::context(context.as_str()).fields::<FieldValues, ()>(),
87+
None => Plan::fields::<FieldValues, ()>(),
88+
};
7889
for field in &input.fields {
7990
let name = field.name.as_str();
8091
builder = match field.verb {
8192
Verb::Encrypt => builder.encrypt::<String>(name),
8293
Verb::EncryptIndex => builder.encrypt_index::<String>(name, Equality),
8394
Verb::Index => builder.index::<String>(name, Equality),
8495
Verb::Passthrough => builder.passthrough::<String>(name),
96+
Verb::ContextField => builder.context_field::<String>(name),
8597
};
8698
if let Some(identity) = &field.identity {
8799
builder = builder.identity(identity.as_str());
88100
}
89101
}
90102
let _ = format!("{builder:?}");
91103

92-
let context = Label::parse(input.context.as_str());
104+
let context = input.context.as_ref().map(|c| Label::parse(c.as_str()));
105+
let context_fields = input
106+
.fields
107+
.iter()
108+
.filter(|f| f.verb == Verb::ContextField)
109+
.count();
110+
let sources = usize::from(context.is_some()) + context_fields;
93111
let names: HashSet<&str> = input.fields.iter().map(|f| f.name.as_str()).collect();
94-
let passthrough_only = context.is_ok()
112+
let passthrough_only = !matches!(context, Some(Err(_)))
95113
&& names.len() == input.fields.len()
96114
&& input.fields.iter().all(|f| f.verb == Verb::Passthrough);
97115

98116
match builder.build() {
99117
Ok(plan) => {
100-
let context = context.expect("a plan that builds has a plain context");
101-
assert_eq!(plan.label(), &context);
102-
assert_eq!(Label::parse(&context.to_string()).as_ref(), Ok(&context));
103-
assert_eq!(plan.fields().len(), input.fields.len());
104-
for (built, declared) in plan.fields().zip(&input.fields) {
118+
assert!(sources <= 1, "a context given {sources} times built");
119+
let context = context.map(|c| c.expect("a plan that builds has a plain context"));
120+
assert_eq!(plan.label(), context.as_ref());
121+
if let Some(context) = &context {
122+
assert_eq!(Label::parse(&context.to_string()).as_ref(), Ok(context));
123+
}
124+
assert_eq!(
125+
plan.context_field().is_some(),
126+
context_fields == 1,
127+
"the plan names its context field"
128+
);
129+
assert_eq!(plan.field_plans().len(), input.fields.len());
130+
for (built, declared) in plan.field_plans().zip(&input.fields) {
105131
assert_eq!(built.name(), declared.name.as_str());
106-
match built.label() {
107-
None => assert_eq!(built.kind(), FieldKind::Passthrough),
108-
Some(label) => {
109-
assert_ne!(built.kind(), FieldKind::Passthrough);
132+
let identity = declared
133+
.identity
134+
.as_ref()
135+
.unwrap_or(&declared.name)
136+
.as_str();
137+
assert_eq!(built.identity(), identity);
138+
let keys_nothing =
139+
matches!(built.kind(), FieldKind::Passthrough | FieldKind::ContextField);
140+
match (built.label(), &context) {
141+
(None, Some(_)) => assert!(keys_nothing),
142+
(None, None) => {
143+
if !keys_nothing {
144+
assert_eq!(
145+
Label::parse(identity).map(|l| l.segments().count()),
146+
Ok(1),
147+
"a sealed field is keyed under one plain segment"
148+
);
149+
}
150+
}
151+
(Some(_), None) => panic!("a plan without a context labelled a field"),
152+
(Some(label), Some(context)) => {
153+
assert!(!keys_nothing);
110154
assert_eq!(Label::parse(&label.to_string()).as_ref(), Ok(label));
111-
let identity = declared
112-
.identity
113-
.as_ref()
114-
.unwrap_or(&declared.name)
115-
.as_str();
116155
let mut expected: Vec<&str> = context.segments().collect();
117156
expected.push(identity);
118157
assert!(label.segments().eq(expected));
@@ -127,6 +166,12 @@ fuzz_target!(|input: Input| {
127166
!passthrough_only,
128167
"passthrough names are under no label, yet refused: {error}"
129168
);
169+
if sources > 1 && !matches!(context, Some(Err(_))) {
170+
assert!(
171+
matches!(error, PlanError::TwoContextSources { .. }),
172+
"a context given twice is refused as that, got {error}"
173+
);
174+
}
130175
let _ = error.to_string();
131176
}
132177
Err(other) => panic!("a plan is refused only with a plan error, got {other:?}"),

‎packages/stack-encrypt/src/cipher.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,10 @@ pub enum Error {
219219
#[error("a field declared decryptable was not opened by its DecryptField implementation")]
220220
NotOpened,
221221
/// A [plan](crate::plan) was refused: it did not validate when it was
222-
/// built, or the value, row or query it was run with does not match it.
223-
/// Always raised before any key is requested.
222+
/// built, or the value, record or query it was run with does not match
223+
/// it. A refusal at build or when a plan runs is raised before any key
224+
/// is requested; [`FieldValues::take`](crate::plan::FieldValues::take)
225+
/// also returns one for a record already in hand.
224226
#[error(transparent)]
225227
Plan(#[from] crate::plan::PlanError),
226228
}

0 commit comments

Comments
 (0)