From ff287a32887d6a6c07c51d64e61b00deb46eed16 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 25 Oct 2026 17:50:03 +1100 Subject: [PATCH] fix(stack-encrypt): open JSON index floats to the exact value sealed The JSON index stores a number leaf as its shortest round-trip text (serde_json::Number::to_string in encode_node) and parses it back when the entry opens (decode_node) or the sealed root opens (PlainJson::parse). serde_json without float_roundtrip parses floats with a fast path that is not guaranteed to return the nearest f64, so a value with a full-precision expansion could open one unit in the last place off: -926809.5051008083 opened as -926809.5051008085, through JsonDocument, JsonNode and JsonSubtree alike. Enable float_roundtrip on stack-encrypt's serde_json dependency. Cargo unifies features, so it reaches every build graph that compiles this crate (the EQL workspace through eql-bindings' stack-encrypt feature, and the Go guests) without a change to their manifests or lockfiles. The read side derives no term from the parsed value: value selectors and ordering terms are computed on the write side only, from the caller's value. A regression test reads the issue's value back four ways, and a property test does the same for arbitrary finite f64 under the generated-documents suite's seed and case count. Both fail without the feature; the shrunk seed is kept in the regressions file. Closes #1364 Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu --- packages/stack-encrypt/CHANGELOG.md | 15 +++ packages/stack-encrypt/Cargo.toml | 9 +- .../proptest-regressions/json_index.txt | 1 + packages/stack-encrypt/tests/json_index.rs | 123 +++++++++++++++++- 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index f2ce6411d..6f1064e71 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **A float in a JSON index document opens to the value that was sealed** + (#1364). A number leaf is stored as its shortest round-trip text and + parsed back when its entry or the sealed root opens, and serde_json's + default float parser can land one unit in the last place off the `f64` + that text names: `-926809.5051008083` opened as `-926809.5051008085`, + through `JsonDocument`, `JsonNode` and `JsonSubtree` alike. The crate now + builds serde_json with `float_roundtrip`, whose parser returns the + nearest `f64`. The trade is slower float parsing and slightly more code. + Cargo unifies features, so every serde_json parse in a program that links + this crate (the caller's own included) uses that parser. Integers and + short decimals were never affected, and the read side derives no term + from the value it parses. + ### Added - **A JSON index, `target::Json`** (`docs/plans/2026-10-17-json-tree-index.md`, diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 5f02bb657..f66de8932 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -72,7 +72,14 @@ ore-rs = { workspace = true } # can act) and gives its structured fields as a `serde_json` map through # `ErrorPayload`. Both are already in the graph through stack-kms. miette = { workspace = true } -serde_json = { workspace = true } +# `float_roundtrip`: the JSON index stores a number leaf as its shortest +# round-trip text and parses it back when the entry or the sealed root +# opens (`json::derive::decode_node`, `json::plain::PlainJson::parse`). +# serde_json's default float parser is fast and can land one unit in the +# last place off the `f64` that text names; this one returns the nearest +# `f64`, so the value opens to the one sealed (#1364). It costs slower +# float parsing and a little more code, and nothing new is compiled. +serde_json = { workspace = true, features = ["float_roundtrip"] } thiserror = { workspace = true } uuid = { workspace = true } zeroize = { workspace = true } diff --git a/packages/stack-encrypt/proptest-regressions/json_index.txt b/packages/stack-encrypt/proptest-regressions/json_index.txt index 341ba3ff3..0e5958ce7 100644 --- a/packages/stack-encrypt/proptest-regressions/json_index.txt +++ b/packages/stack-encrypt/proptest-regressions/json_index.txt @@ -4,3 +4,4 @@ # # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. +cc 3a9d08b71f8b70cf7c0cebd989de39291421711362e5e5267242443d02e2c037 # shrinks to -5.7483763378806435e-244 diff --git a/packages/stack-encrypt/tests/json_index.rs b/packages/stack-encrypt/tests/json_index.rs index f4d42856e..e6797107e 100644 --- a/packages/stack-encrypt/tests/json_index.rs +++ b/packages/stack-encrypt/tests/json_index.rs @@ -1775,7 +1775,7 @@ mod given_generated_documents { /// proptest's defaults, which read `PROPTEST_CASES` and /// `PROPTEST_RNG_SEED`, with this suite's own default for each one left /// unset. - fn config() -> Config { + pub(super) fn config() -> Config { let mut config = Config::default(); if std::env::var_os("PROPTEST_CASES").is_none() { config.cases = DEFAULT_CASES; @@ -2179,6 +2179,127 @@ mod given_generated_documents { } } +mod given_a_float_at_full_precision { + //! A number leaf is stored as its shortest round-trip text and parsed + //! back when it opens: from the sealed root's document text, or from + //! the node's own entry. Only serde_json's `float_roundtrip` parser + //! promises the nearest `f64` to that text; without it a value can come + //! back one unit in the last place off (#1364). Each case seals a + //! document holding the number and reads it back every way the column + //! hands it out, comparing bit patterns. + + use proptest::prelude::*; + use proptest::test_runner::TestRunner; + + use super::*; + + /// The value a property test found coming back as + /// `-926809.5051008085` (#1364). + const FOUND: f64 = -926_809.505_100_808_3; + + /// The document a case seals: the number as an array element under a + /// key, so a walk under `$.list` and the extracted element both reach + /// it. Its entries, by ordinal: `$`, `$.list`, `$.list[0]`. + fn document(f: f64) -> Value { + json!({"list": [f]}) + } + + /// The number back out of each way of reading it, named. + async fn read_back(cipher: &Cipher, f: f64) -> Vec<(&'static str, Value)> { + let value = document(f); + let sealed = seal(cipher, &value).await; + let entries = seal_with(cipher, &value, &open_root()).await; + let key = subtree_key(cipher, "$.list", JsonOptions::default()) + .await + .unwrap(); + let rows = subtree_nodes(&sealed, key.token_bytes()); + let element = sealed.node(2).unwrap(); + vec![ + ( + "the document, from the sealed root", + open(cipher, sealed).await.unwrap()["list"][0].take(), + ), + ( + "the document, from its entries", + open(cipher, entries).await.unwrap()["list"][0].take(), + ), + ( + "the extracted element, as `->` returns it", + cipher + .decrypt_as::(element, aead()) + .await + .unwrap(), + ), + ( + "the walk under $.list", + cipher + .decrypt_as::(key.open(rows), aead()) + .await + .unwrap()[0] + .take(), + ), + ] + } + + /// The bit pattern of `value` as an `f64`, or `None` for a value that + /// is not a number. + fn bits(value: &Value) -> Option { + value.as_f64().map(f64::to_bits) + } + + #[tokio::test] + async fn reads_back_the_value_that_came_back_one_ulp_off() { + let Scenario { cipher, .. } = scenario().await; + let wrong: Vec = read_back(&cipher, FOUND) + .await + .into_iter() + .filter(|(_, back)| bits(back) != Some(FOUND.to_bits())) + .map(|(how, back)| format!("{how} read back {back}")) + .collect(); + assert!( + wrong.is_empty(), + "every read should give back {FOUND:?}: {wrong:#?}" + ); + } + + /// Any finite `f64` reads back bit for bit, every way. The cases run + /// under the generated-documents suite's configuration: its fixed seed + /// and case count, each overridden by `PROPTEST_RNG_SEED` and + /// `PROPTEST_CASES`, and its regressions file. + #[test] + fn reads_back_any_finite_f64_bit_for_bit() { + let config = given_generated_documents::config(); + let replay = format!( + "seed {seed}, {cases} cases; replay with PROPTEST_RNG_SEED={seed} PROPTEST_CASES={cases}", + seed = config.rng_seed, + cases = config.cases + ); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let Scenario { cipher, .. } = runtime.block_on(scenario()); + let finite = proptest::num::f64::ANY.prop_filter("a finite f64", |f| f.is_finite()); + let mut runner = TestRunner::new(config); + let outcome = runner.run(&finite, |f| { + for (how, back) in runtime.block_on(read_back(&cipher, f)) { + prop_assert_eq!( + bits(&back), + Some(f.to_bits()), + "{} should read back {:?}, read back {}", + how, + f, + back + ); + } + Ok(()) + }); + if let Err(error) = outcome { + panic!("json_index full-precision floats: {replay}\n{error}"); + } + } +} + // ============================================================================= // Queries // =============================================================================