Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/stack-encrypt/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
9 changes: 8 additions & 1 deletion packages/stack-encrypt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
123 changes: 122 additions & 1 deletion packages/stack-encrypt/tests/json_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::<Value, JsonNode>(element, aead())
.await
.unwrap(),
),
(
"the walk under $.list",
cipher
.decrypt_as::<Value, JsonSubtree>(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<u64> {
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<String> = 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
// =============================================================================
Expand Down
Loading