Skip to content

Commit 7fe56ec

Browse files
committed
fix(stack-encrypt): refuse a NaN either way in read, and test the typed-field gaps
dynamic::read treated a NaN differently in its two float directions: read(Float64, Float32(NaN)) returned Float64(NaN), while read(Float32, Float64(NaN)) refused it, and the doc said "a NaN is never exact". Converting a NaN is never exact, since it equals nothing, so both directions now refuse it. A NaN already of the field's own float kind is still returned as it is, like any value of the kind. Tests for paths review found unpinned: - decrypt with an index-only field before a typed field. Opened values are paired with the fields that have a ciphertext, not with every plan field; pairing with every field makes this test fail (checked), where every existing test passed. - a typed "object" or "array" field round-trips, empty or not, and opens as its declared kind. - check_record accepts a record sealed as another type (it cannot open the leaf to see the tag), and decrypt refuses it. - a field spec with "type" before "outputs" parses and is checked the same way. - read at the first float past each integer range (2^63, 2^64, 2^32) is refused and the last value inside converts exactly, so a saturating `f as u64` cast would fail. - read refuses a NaN in both float directions. The query-term test's variable is renamed from probe to query, the glossary's word.
1 parent 325d2b0 commit 7fe56ec

2 files changed

Lines changed: 173 additions & 11 deletions

File tree

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

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ pub fn admits(kind: ValueKind, index: &IndexSpec) -> bool {
7272
/// width or kind is converted when the conversion is exact: an integer in
7373
/// range, or a float with no fractional part in range, for an integer kind
7474
/// (a JavaScript `34` arrives as a float); an integer or float the target
75-
/// float kind represents exactly, for a float kind. Nothing else converts:
76-
/// a string is never parsed as a number, and a number never becomes a
77-
/// string.
75+
/// float kind represents exactly, for a float kind. A NaN is never
76+
/// converted, in either float direction: it equals nothing, so no
77+
/// conversion of it is exact. Nothing else converts: a string is never
78+
/// parsed as a number, and a number never becomes a string.
7879
///
7980
/// This is for a query: what a host hands over to search with. A value
8081
/// being sealed is not converted; it must already be of the field's kind,
@@ -163,19 +164,20 @@ impl Number {
163164
}
164165

165166
/// The `f64` this number is exactly, if it is one: an integer whose
166-
/// conversion does not round.
167+
/// conversion does not round, or a float that is not a NaN. A NaN is
168+
/// never exact, because it does not equal itself.
167169
fn exact_f64(self) -> Option<f64> {
168170
match self {
169-
Number::Float(f) => Some(f),
171+
Number::Float(f) => (!f.is_nan()).then_some(f),
170172
Number::Integer(i) => {
171173
let f = i as f64;
172174
(f as i128 == i).then_some(f)
173175
}
174176
}
175177
}
176178

177-
/// The `f32` this number is exactly, if it is one. A NaN is never
178-
/// exact, because it does not equal itself.
179+
/// The `f32` this number is exactly, if it is one. A NaN is refused by
180+
/// `exact_f64` before it gets here.
179181
fn exact_f32(self) -> Option<f32> {
180182
let f = self.exact_f64()?;
181183
let narrowed = f as f32;
@@ -284,9 +286,14 @@ mod tests {
284286
let read = read_ok(kind, sample(kind)).expect("its own kind");
285287
assert!(kind.holds(&read));
286288
}
289+
// Already of the kind: returned as it is, not converted, so a NaN
290+
// the host sent as the field's own float kind is not refused here.
287291
assert!(
288292
matches!(read_ok(ValueKind::Float32, FfiValue::Float32(f32::NAN)), Some(FfiValue::Float32(f)) if f.is_nan())
289293
);
294+
assert!(
295+
matches!(read_ok(ValueKind::Float64, FfiValue::Float64(f64::NAN)), Some(FfiValue::Float64(f)) if f.is_nan())
296+
);
290297
}
291298

292299
#[test]
@@ -359,6 +366,34 @@ mod tests {
359366
}
360367
}
361368

369+
/// The first float past each integer range is refused, and the last
370+
/// one inside it converts exactly. A float cast straight to the integer
371+
/// type (`f as u64`) would saturate instead: a JavaScript 2^64 would
372+
/// read as `u64::MAX` and derive a term for a different number.
373+
#[test]
374+
fn read_refuses_a_float_at_the_first_value_past_an_integer_range() {
375+
let two_63 = 9_223_372_036_854_775_808.0_f64;
376+
let two_64 = 18_446_744_073_709_551_616.0_f64;
377+
assert!(read_ok(ValueKind::Int64, FfiValue::Float64(two_63)).is_none());
378+
assert!(matches!(
379+
read_ok(ValueKind::Int64, FfiValue::Float64(-two_63)),
380+
Some(FfiValue::Int64(i64::MIN))
381+
));
382+
assert!(read_ok(ValueKind::UInt64, FfiValue::Float64(two_64)).is_none());
383+
assert!(matches!(
384+
read_ok(
385+
ValueKind::UInt64,
386+
FfiValue::Float64(18_446_744_073_709_549_568.0)
387+
),
388+
Some(FfiValue::UInt64(18_446_744_073_709_549_568))
389+
));
390+
assert!(read_ok(ValueKind::UInt32, FfiValue::Float64(4_294_967_296.0)).is_none());
391+
assert!(matches!(
392+
read_ok(ValueKind::UInt32, FfiValue::Float64(4_294_967_295.0)),
393+
Some(FfiValue::UInt32(u32::MAX))
394+
));
395+
}
396+
362397
#[test]
363398
fn read_converts_an_exactly_representable_number_to_a_float_kind() {
364399
assert!(
@@ -386,7 +421,6 @@ mod tests {
386421
(ValueKind::Float64, FfiValue::UInt64(u64::MAX)),
387422
(ValueKind::Float32, FfiValue::UInt32(16_777_217)),
388423
(ValueKind::Float32, FfiValue::Float64(0.1)),
389-
(ValueKind::Float32, FfiValue::Float64(f64::NAN)),
390424
];
391425
for (at, (kind, value)) in refused.into_iter().enumerate() {
392426
assert!(
@@ -396,6 +430,20 @@ mod tests {
396430
}
397431
}
398432

433+
/// A NaN converts in neither float direction: it equals nothing, so no
434+
/// conversion of it is exact, and a query for one could match nothing.
435+
#[test]
436+
fn read_refuses_a_nan_in_both_float_directions() {
437+
assert!(matches!(
438+
read(ValueKind::Float32, FfiValue::Float64(f64::NAN)),
439+
Err(Error::Source)
440+
));
441+
assert!(matches!(
442+
read(ValueKind::Float64, FfiValue::Float32(f32::NAN)),
443+
Err(Error::Source)
444+
));
445+
}
446+
399447
#[test]
400448
fn read_never_converts_across_kinds() {
401449
let refused = [

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

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2352,6 +2352,32 @@ mod tests {
23522352
);
23532353
}
23542354

2355+
/// The parser applies `"type"` after it has read every key, so a
2356+
/// spec that declares its type before its outputs is checked the
2357+
/// same way: admitted when the type admits the indexes, refused
2358+
/// when it does not.
2359+
#[test]
2360+
fn the_plan_reads_a_type_given_before_the_outputs() {
2361+
let early = |ty: &str| {
2362+
obj(vec![
2363+
("type", s(ty)),
2364+
("outputs", strings(&["c", "eq"])),
2365+
("context", s("users/age")),
2366+
])
2367+
};
2368+
let parsed = plan(obj(vec![("age", early("uint64"))])).expect("parses");
2369+
assert_eq!(parsed.fields()[0].field_type(), Some(ValueKind::UInt64));
2370+
assert_eq!(
2371+
parsed.fields()[0].outputs(),
2372+
[Output::Ciphertext, Output::Term(IndexSpec::Equality)]
2373+
);
2374+
let refused = plan(obj(vec![("age", early("float64"))]));
2375+
assert!(
2376+
matches!(refused, Err(Error::Plan)),
2377+
"equality on a float is refused whatever the key order: {refused:?}"
2378+
);
2379+
}
2380+
23552381
#[test]
23562382
fn the_plan_refuses_an_unresolvable_type_or_one_that_does_not_admit_an_index() {
23572383
let refused: [(&str, FfiValue); 8] = [
@@ -2552,15 +2578,103 @@ mod tests {
25522578
let value =
25532579
crate::dynamic::read(declared, FfiValue::Float64(34.0)).expect("an exact 34");
25542580
let scalar = Scalar::of(&value, &IndexSpec::Equality).expect("scalar");
2555-
let probe = term(
2581+
let query = term(
25562582
&keyset,
25572583
scalar,
25582584
&IndexSpec::Equality,
25592585
field.view().expect("view"),
25602586
)
25612587
.await
2562-
.expect("probe");
2563-
assert_eq!(probe, stored);
2588+
.expect("query term");
2589+
assert_eq!(query, stored);
2590+
}
2591+
2592+
/// An index-only field has no ciphertext, so it is not among the
2593+
/// opened values. The typed field after it is checked against its
2594+
/// own type, not against the index-only field's.
2595+
#[tokio::test]
2596+
async fn decrypt_skips_an_index_only_field_when_it_checks_types() {
2597+
let cipher = cipher().await;
2598+
let keyset = cipher.default_keyset();
2599+
let plan = plan(obj(vec![
2600+
("age", typed("users/age", &["eq"], "uint64")),
2601+
("name", typed("users/name", &["c"], "string")),
2602+
]))
2603+
.expect("plan");
2604+
let sealed = encrypt(
2605+
&keyset,
2606+
obj(vec![("age", FfiValue::UInt64(34)), ("name", s("bob"))]),
2607+
&plan,
2608+
)
2609+
.await
2610+
.expect("seal");
2611+
let opened = object(
2612+
decrypt(Scope::Client(&cipher), sealed, &plan)
2613+
.await
2614+
.expect("name is checked as a string, not as a uint64"),
2615+
);
2616+
assert_eq!(keys(&opened), ["name"]);
2617+
assert_eq!(text_of(&opened[0].1), "bob");
2618+
}
2619+
2620+
/// A typed composite field opens as its declared kind, with its
2621+
/// entries, including when it is empty: an empty `[]` that opened
2622+
/// as `{}` would fail every read of an `"array"` field.
2623+
#[tokio::test]
2624+
async fn a_typed_composite_field_round_trips_even_when_empty() {
2625+
let cipher = cipher().await;
2626+
let keyset = cipher.default_keyset();
2627+
for (ty, value, len) in [
2628+
("object", obj(vec![("home", s("a@x"))]), 1),
2629+
("object", FfiValue::Object(vec![]), 0),
2630+
(
2631+
"array",
2632+
FfiValue::Array(vec![s("a"), FfiValue::UInt32(1)]),
2633+
2,
2634+
),
2635+
("array", FfiValue::Array(vec![]), 0),
2636+
] {
2637+
let plan = plan(obj(vec![("doc", typed("users/doc", &["c"], ty))])).expect("plan");
2638+
let sealed = encrypt(&keyset, obj(vec![("doc", value)]), &plan)
2639+
.await
2640+
.expect("seal");
2641+
let opened = decrypt(Scope::Client(&cipher), sealed, &plan)
2642+
.await
2643+
.expect("a composite opens as its declared kind");
2644+
let (_, doc) = object(opened).into_iter().next().expect("doc");
2645+
match (ty, doc) {
2646+
("object", FfiValue::Object(entries)) => assert_eq!(entries.len(), len),
2647+
("array", FfiValue::Array(items)) => assert_eq!(items.len(), len),
2648+
(ty, _) => panic!("a {ty} field opened as another kind"),
2649+
}
2650+
}
2651+
}
2652+
2653+
/// `check_record` does not open anything, so it cannot see a
2654+
/// typed field's type: the tag is inside the AEAD envelope. A record
2655+
/// sealed as another type passes it, and only `decrypt` refuses it.
2656+
#[tokio::test]
2657+
async fn check_record_accepts_a_record_sealed_as_another_type() {
2658+
let cipher = cipher().await;
2659+
let keyset = cipher.default_keyset();
2660+
let untyped = plan(obj(vec![("age", spec(s("users/age"), &["c"]))])).expect("plan");
2661+
let as_uint64 =
2662+
plan(obj(vec![("age", typed("users/age", &["c"], "uint64"))])).expect("plan");
2663+
2664+
let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped)
2665+
.await
2666+
.expect("seal");
2667+
check_record(sealed, &as_uint64).expect("the type is not visible without opening");
2668+
2669+
let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped)
2670+
.await
2671+
.expect("seal");
2672+
let result = decrypt(Scope::Client(&cipher), sealed, &as_uint64).await;
2673+
assert!(
2674+
matches!(result, Err(Error::Record)),
2675+
"decrypt is where the type is checked: {:?}",
2676+
result.err()
2677+
);
25642678
}
25652679
}
25662680
}

0 commit comments

Comments
 (0)