Skip to content

Commit ca51ff4

Browse files
committed
refactor(stack-encrypt): read a record plan from its frame with serde, not FfiValue
record::plan and plan_with took an FfiValue tree and walked it by hand: the object, each field spec's keys, the outputs list, each index's wire form and a match index's options. They now take the plan's frame bytes and read them with serde (vitaminc_aead_frame::serde). One small visitor reads the top-level map, to tell the reserved "context_field" key from a field's name; everything below it is derived: - a field spec is a deny_unknown_fields struct, with exactly one of "outputs" and "target" checked in TryFrom, and a present key that is null refused through a three-line `present` helper; - an output is an externally tagged enum, so "match", {"match": null} and {"match": {...}} are one variant, Match(Option<MatchWire>); - match options default field by field and are checked against the scheme's bounds in TryFrom; - a "type" is a try_from newtype over ValueKind's names; - a field's context is the context visitor's. Every Reason a plan was refused with is kept: plan_refusal reads the field from the error's path and the reason from where in the field it happened, or from the reason a field's own rule gave. Two changes, both from the frame: a key, field or match option given twice is refused by the frame before the plan is read, as NotAnObject naming no field (RepeatedKey and DuplicateField before); and a missing index payload and a null one mean the same, so {"eq": null} is a bare "eq". IndexSpec::from_frame replaces IndexSpec::from_value, and Output::from_value is gone. The Go guest checks a plan is a frame, then hands its bytes over. Unit tests build plans from FfiValue through the shared test helper, and integration tests write them as JSON serialised straight to frame bytes; the fuzz target sends each plan as its frame. Refs #1252.
1 parent 8555904 commit ca51ff4

14 files changed

Lines changed: 576 additions & 350 deletions

File tree

‎.cargo/mutants.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ exclude_re = [
9696
# `true` there refuses the same values with the same error. The guard stays
9797
# so the model says what it holds without leaning on the writer behind it.
9898
'stack-encrypt/src/dynamic/value_frame\.rs:\d+:\d+: replace match guard logical\.admits\(&value\) with true in model$',
99+
# A serde visitor's `expecting` writes only the "expected ..." half of a
100+
# serde error's message, and no caller of these three reads that message:
101+
# a context, statistics and a plan's root are each refused with one fixed
102+
# `Reason` (`ContextKind`, `NotStats`, `NotAnObject`), and the plan's
103+
# field-level reasons are read from the error's path, so writing nothing
104+
# refuses the same input with the same error.
105+
'stack-encrypt/src/dynamic/(context|query|record)\.rs:\d+:\d+: replace <impl (Visitor<\x27de> for ContextVisitor|serde::Deserialize<\x27de> for Confidence>::deserialize::<impl serde::de::Visitor<\x27_> for V|Deserialize<\x27de> for PlanWire>::deserialize::<impl Visitor<\x27de> for V)>::expecting -> fmt::Result with Ok\(Default::default\(\)\)$',
99106
]
100107

101108
# Headroom over the measured baseline before a slow-but-correct mutant is

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ eql = ["dep:eql-bindings"]
4747
# HTTP comes from the host (see `wasm:wasi-check` in the suite root).
4848
stack-auth = { path = "../../../../packages/stack-auth", default-features = false }
4949
# `dynamic`: the runtime value model this guest speaks — contexts, index
50-
# terms and record plans over `FfiValue`, shared with every other binding.
50+
# terms and record plans read from frames, shared with every other binding.
5151
stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false, features = ["dynamic"] }
5252
# The guest ABI every guest under bindings/go shares: the allocator and
5353
# buffer registry (and with them the `se_alloc`/`se_dealloc` exports), the

‎languages/golang/encrypt/guest/src/ops.rs‎

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@
4242
//! values to verify are read as [`ValueFrame`]s: the frame is checked against
4343
//! the engine's value model, and the engine walks it, splitting a source into
4444
//! one frame per field as it goes. A record opens into one frame, which is
45-
//! the output. Control data (a plan, an options object, query statistics)
46-
//! carries no secret and is decoded into an [`FfiValue`].
45+
//! the output. Control data (a plan, a context, an options object, query
46+
//! statistics) carries no secret and is read from its frame with serde.
4747
4848
use stack_encrypt::dynamic::{self, FfiValue, Scope, ValueFrame};
4949
use stack_encrypt::sem::MatchOptions;
@@ -357,11 +357,13 @@ fn parse_query<'f>(
357357
Ok((plan, field, operator))
358358
}
359359

360-
/// A codec-encoded plan, parsed against this build's resolver: a target
361-
/// name this build cannot run is refused here, the same way at every
362-
/// export that takes a plan.
360+
/// A plan's frame, parsed against this build's resolver: a target name this
361+
/// build cannot run is refused here, the same way at every export that
362+
/// takes a plan. Bytes that are not a frame are refused as every other
363+
/// input's are, before the plan's own grammar.
363364
fn parse_plan(plan: &[u8]) -> Result<dynamic::record::Plan, u32> {
364-
dynamic::record::plan_with(decode_value(plan)?, &resolver()).map_err(|e| fail_dynamic(&e))
365+
let _ = parse_frame(plan)?;
366+
dynamic::record::plan_with(plan, &resolver()).map_err(|e| fail_dynamic(&e))
365367
}
366368

367369
// =============================================================================
@@ -518,11 +520,6 @@ pub mod validate {
518520
// Codec glue
519521
// =============================================================================
520522

521-
/// Control data from a frame: a plan, an options object, statistics.
522-
fn decode_value(bytes: &[u8]) -> Result<FfiValue, u32> {
523-
FfiValue::from_frame(bytes).map_err(|_| not_a_frame())
524-
}
525-
526523
/// Plaintext from a frame: checked against the engine's value model and
527524
/// held as the frame, in a buffer wiped when it drops. The same frames are
528525
/// refused as control data's, so a malformed input fails the same way on
@@ -832,7 +829,7 @@ mod tests {
832829
#[test]
833830
fn targets_lists_what_this_build_holds_in_the_wire_shape() {
834831
let out = targets().expect("encodes");
835-
let FfiValue::Object(entries) = decode_value(&out).expect("decodes") else {
832+
let FfiValue::Object(entries) = FfiValue::from_frame(&out).expect("decodes") else {
836833
panic!("an object");
837834
};
838835
assert_eq!(entries.len(), 1);
@@ -955,7 +952,7 @@ mod tests {
955952
validate::record_tree(&sealed, &plan, None).expect("the tree fits");
956953
let opened =
957954
block_on(decrypt_record(Scope::Client(&cipher), &sealed, &plan, None)).expect("opens");
958-
let FfiValue::Object(values) = decode_value(&opened).expect("a value") else {
955+
let FfiValue::Object(values) = FfiValue::from_frame(&opened).expect("a value") else {
959956
panic!("an object")
960957
};
961958
assert!(
@@ -968,7 +965,7 @@ mod tests {
968965
let probe = encoded(text("alice@example.com"));
969966
validate::query_term(&probe, &plan, b"email", b"eq").expect("a target field answers eq");
970967
let term = block_on(query_term(&keyset, &probe, &plan, b"email", b"eq")).expect("derives");
971-
let FfiValue::Object(term) = decode_value(&term).expect("a value") else {
968+
let FfiValue::Object(term) = FfiValue::from_frame(&term).expect("a value") else {
972969
panic!("a query term is an object")
973970
};
974971
assert!(

‎packages/stack-encrypt/CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
272272
are contexts: an 8- or 16-bit integer, which was widened to 32 bits
273273
before, and a 128-bit one are `Reason::ContextKind`, as are bytes that
274274
are not one frame.
275+
- `record::plan` and `record::plan_with` take the plan's frame bytes.
276+
`IndexSpec::from_frame` replaces `IndexSpec::from_value`, and
277+
`Output::from_value` is gone. Each `Reason` a plan was refused with is
278+
unchanged, with two exceptions: a key, a field or a match option given
279+
twice is refused by the frame before the plan is read, as
280+
`Reason::NotAnObject` naming no field (it was `RepeatedKey` or
281+
`DuplicateField`), and so are bytes that are not one frame. An index's
282+
payload may be missing or null alike: `{"match": null}` is a bare
283+
`"match"`, and `{"eq": null}` a bare `"eq"`.
275284
- `Stats::from_frame` replaces `Stats::from_value`.
276285

277286
- **A match value with no tokens is stored as the empty filter, so

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,12 @@ fuzz_target!(|case: Case| {
401401
let Case { plan: spec, record } = case;
402402
// The plan parser is fuzzed for panics only: its rules are a separate
403403
// model, and a plan that does not parse has no record to check.
404-
let Ok(plan) = plan_with(spec.into_value(), &Fixed) else {
404+
// The plan crosses as a binding sends it, as frame bytes; a spec the
405+
// frame cannot carry is no plan.
406+
let Ok(frame) = spec.into_value().to_frame() else {
407+
return;
408+
};
409+
let Ok(plan) = plan_with(frame.risky_ref(), &Fixed) else {
405410
if trace {
406411
eprintln!("verdict: plan refused");
407412
}

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@
2121
//! indexed by walking its frame into the encrypting sink and the term
2222
//! derivers; and a record opens straight into a frame (vitaminc ADR 0001: a
2323
//! value describes itself once, and sinks consume it). A binding's control
24-
//! data (a plan, a context, options, a configuration, whose credentials are
25-
//! secret) is an [`FfiValue`] tree, with its text and bytes wiped.
24+
//! data (a plan, a context, query statistics) is read from its frame by
25+
//! serde (`vitaminc_aead_frame::serde`) into a typed message, with no tree
26+
//! built.
2627
//!
2728
//! # What is here
2829
//!
29-
//! * [`context`](context()) — an [`FfiValue`] read as an encryption context.
30+
//! * [`context`](context()) — a frame read as an encryption context.
3031
//! * [`ValueFrame`] — the engine's plaintext for a value whose type is known
3132
//! only at run time, and the value model it holds.
3233
//! * [`term`](term()) — one index term for a value, derived at the one
@@ -159,8 +160,9 @@ impl<K> fmt::Debug for Scope<'_, K> {
159160
}
160161
}
161162

162-
/// The UTF-8 inside a string leaf. Valid by `Utf8String`'s construction
163-
/// invariant; checked rather than assumed because this is boundary code.
163+
/// The UTF-8 inside a string leaf, for tests. Valid by `Utf8String`'s
164+
/// construction invariant; checked rather than assumed.
165+
#[cfg(test)]
164166
fn utf8(s: &Utf8String) -> Option<&str> {
165167
std::str::from_utf8(s.risky_ref()).ok()
166168
}

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

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -473,24 +473,18 @@ fn answers(
473473
/// with [`operator_to_value`].
474474
///
475475
/// ```
476-
/// use stack_encrypt::dynamic::{field_operators, record, FfiValue, NoTargets, OpSpec};
476+
/// use stack_encrypt::dynamic::{field_operators, record, NoTargets, OpSpec};
477477
/// use stack_encrypt::target::IndexSpec;
478478
///
479-
/// let text = |s: &str| FfiValue::String(s.into());
480-
/// let plan = record::plan(FfiValue::Object(vec![(
481-
/// "age".into(),
482-
/// FfiValue::Object(vec![
483-
/// ("context".into(), FfiValue::Array(vec![text("users"), text("age")])),
484-
/// ("outputs".into(), FfiValue::Array(vec![text("c"), text("eq"), text("ore")])),
485-
/// ("type".into(), text("uint32")),
486-
/// ]),
487-
/// )]))?;
479+
/// let plan = record::plan(&vitaminc_aead_frame::serde::to_vec(&serde_json::json!({
480+
/// "age": { "context": ["users", "age"], "outputs": ["c", "eq", "ore"], "type": "uint32" },
481+
/// }))?)?;
488482
///
489483
/// let operators = field_operators(&plan, "age", &NoTargets)?;
490484
/// assert_eq!(operators[0], (OpSpec::Eq, IndexSpec::Equality));
491485
/// assert_eq!(operators[1], (OpSpec::Lt, IndexSpec::Ore));
492486
/// assert_eq!(operators.len(), 6, "eq, the four comparisons and between");
493-
/// # Ok::<(), stack_encrypt::dynamic::Error>(())
487+
/// # Ok::<(), Box<dyn std::error::Error>>(())
494488
/// ```
495489
///
496490
/// # Errors
@@ -699,15 +693,9 @@ pub fn check_query(
699693
/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
700694
/// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?;
701695
/// let keyset = cipher.default_keyset();
702-
/// let text = |s: &str| FfiValue::String(s.into());
703-
/// let plan = record::plan(FfiValue::Object(vec![(
704-
/// "age".into(),
705-
/// FfiValue::Object(vec![
706-
/// ("context".into(), FfiValue::Array(vec![text("users"), text("age")])),
707-
/// ("outputs".into(), FfiValue::Array(vec![text("c"), text("ore")])),
708-
/// ("type".into(), text("uint32")),
709-
/// ]),
710-
/// )]))?;
696+
/// let plan = record::plan(&vitaminc_aead_frame::serde::to_vec(&serde_json::json!({
697+
/// "age": { "context": ["users", "age"], "outputs": ["c", "ore"], "type": "uint32" },
698+
/// }))?)?;
711699
///
712700
/// let term = query_term(&keyset, &plan, "age", OpSpec::Lt, FfiValue::UInt32(40), &NoTargets)?.await?;
713701
///
@@ -1020,6 +1008,7 @@ pub fn limit_to_value(limit: Limit) -> FfiValue {
10201008
#[cfg(test)]
10211009
mod tests {
10221010
use super::*;
1011+
use crate::dynamic::test_frame::frame_of;
10231012

10241013
/// Statistics as a binding sends them.
10251014
fn stats_frame(value: &FfiValue) -> Result<Stats, Error> {
@@ -1071,14 +1060,14 @@ mod tests {
10711060
"match".into(),
10721061
FfiValue::Object(vec![("downcase".into(), FfiValue::Bool(false))]),
10731062
)]);
1074-
record::plan(FfiValue::Object(vec![
1063+
record::plan(&frame_of(&FfiValue::Object(vec![
10751064
field("email", "string", vec![text("c"), text("eq")]),
10761065
field("name", "string", vec![text("c"), text("match")]),
10771066
field("age", "uint32", vec![text("c"), text("ope"), text("ore")]),
10781067
field("score", "int64", vec![text("ope")]),
10791068
field("nickname", "string", vec![text("c"), text("ore")]),
10801069
field("handle", "string", vec![text("c"), case_sensitive]),
1081-
]))
1070+
])))
10821071
.expect("the plan parses")
10831072
}
10841073

@@ -1471,11 +1460,11 @@ mod tests {
14711460
]),
14721461
)
14731462
};
1474-
let plan = record::plan(FfiValue::Object(vec![
1463+
let plan = record::plan(&frame_of(&FfiValue::Object(vec![
14751464
("context_field".into(), text("tenant")),
14761465
one("tenant", "string", "passthrough"),
14771466
one("email", "string", "eq"),
1478-
]))
1467+
])))
14791468
.unwrap();
14801469
let error = query_term(&keyset, &plan, "email", OpSpec::Eq, text("a"), &NoTargets)
14811470
.map(drop)
@@ -1860,13 +1849,13 @@ mod tests {
18601849
use vitaminc_aead::plaintext::{Int, Logical, Physical, Raw, TimeUnit};
18611850
let cipher = cipher().await;
18621851
let keyset = cipher.default_keyset();
1863-
let plan = record::plan(FfiValue::Object(vec![
1852+
let plan = record::plan(&frame_of(&FfiValue::Object(vec![
18641853
field("tiny", "int8", vec![text("c"), text("ore")]),
18651854
field("huge", "int128", vec![text("c"), text("ope")]),
18661855
field("day", "date", vec![text("c"), text("eq")]),
18671856
field("at", "timestamp", vec![text("c"), text("eq")]),
18681857
field("price", "decimal", vec![text("c"), text("eq")]),
1869-
]))
1858+
])))
18701859
.expect("the plan parses");
18711860
let frame = |ty: Option<Logical>, value: Physical<'_>| {
18721861
ValueFrame::of(Raw(ty, value)).expect("a value")
@@ -2368,7 +2357,7 @@ mod tests {
23682357

23692358
fn match_type_plan() -> Plan {
23702359
record::plan_with(
2371-
FfiValue::Object(vec![(
2360+
&frame_of(&FfiValue::Object(vec![(
23722361
"name".into(),
23732362
FfiValue::Object(vec![
23742363
(
@@ -2377,7 +2366,7 @@ mod tests {
23772366
),
23782367
("target".into(), text("Some")),
23792368
]),
2380-
)]),
2369+
)])),
23812370
&MatchType,
23822371
)
23832372
.unwrap()
@@ -2547,7 +2536,7 @@ mod tests {
25472536
let cipher = cipher().await;
25482537
let keyset = cipher.default_keyset();
25492538
let plan = record::plan_with(
2550-
FfiValue::Object(vec![(
2539+
&frame_of(&FfiValue::Object(vec![(
25512540
"email".into(),
25522541
FfiValue::Object(vec![
25532542
(
@@ -2556,7 +2545,7 @@ mod tests {
25562545
),
25572546
("target".into(), text("Some")),
25582547
]),
2559-
)]),
2548+
)])),
25602549
&OneType,
25612550
)
25622551
.unwrap();

0 commit comments

Comments
 (0)