diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58017dc..c79cbc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,13 @@ jobs: - run: cargo fmt --check - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo test --workspace --all-features + # Library consumers build these crates without serde_json's global + # arbitrary_precision; test that configuration and keep it out of their graph. + - run: cargo test -p octoscript-schema -p octoscript-capabilities -p octoscript-workflow + - run: > + ! cargo tree -e normal,features -i serde_json + -p octoscript-schema -p octoscript-capabilities -p octoscript-workflow + | grep arbitrary_precision - run: cargo check --locked -p octoscript-sandbox --tests --target aarch64-unknown-linux-gnu - run: cargo check --locked -p octoscript-sandbox --tests --target riscv64gc-unknown-linux-gnu - run: cargo check --locked -p octoscript-sandbox --tests --target x86_64-pc-windows-gnu diff --git a/README.md b/README.md index 6389f00..09ac372 100644 --- a/README.md +++ b/README.md @@ -734,6 +734,9 @@ both keys with JSON `null`. See [editor workflow-data projection](docs/workflow- fixed-file and feature-gated HTTP endpoint/origin catalogs, aggregate catalog limits, safe host bridge, and a sealed static-catalog mobile/embedded profile. - `octoscript-schema`: bounded executable JSON-schema subset for tool contracts. + Exact number bounds beyond `i64`/`u64`/`f64` precision are the opt-in + `exact-numbers` feature (serde_json `arbitrary_precision`), off in libraries + and on in the `octoscript` CLI; see [numeric precision](docs/schema-contracts.md#numeric-precision). - `octoscript-storage`: host-only authenticated records, rollback protection, and fenced compare-and-swap backend boundary, plus an optional anchored SQLite payload adapter that requires a platform trust anchor and a bounded diff --git a/crates/octoscript-capabilities/Cargo.toml b/crates/octoscript-capabilities/Cargo.toml index ed22acf..2aee620 100644 --- a/crates/octoscript-capabilities/Cargo.toml +++ b/crates/octoscript-capabilities/Cargo.toml @@ -19,13 +19,15 @@ serde_json = "1.0.150" octoscript-core = { path = "../octoscript-core" } octoscript-protocol = { version = "0.1.0", path = "../octoscript-protocol" } octoscript-sandbox = { version = "0.1.0", path = "../octoscript-sandbox", optional = true } -octoscript-schema = { version = "0.1.0", path = "../octoscript-schema" } +octoscript-schema = { version = "0.1.0", path = "../octoscript-schema", default-features = false } octoscript-storage = { version = "0.1.0", path = "../octoscript-storage", optional = true } octoscript-worker = { version = "0.1.0", path = "../octoscript-worker", optional = true } ureq = { version = "3.3.0", default-features = false, features = ["rustls"], optional = true } zeroize = { version = "1.9.0", optional = true } [features] +# Opt-in exact schema number bounds; see octoscript-schema/exact-numbers. +exact-numbers = ["octoscript-schema/exact-numbers"] bubblewrap-watchdog = ["dep:octoscript-sandbox"] in-process-worker = ["dep:octoscript-worker"] json-line-worker = [] diff --git a/crates/octoscript-cli/Cargo.toml b/crates/octoscript-cli/Cargo.toml index e3a7baf..1065d8b 100644 --- a/crates/octoscript-cli/Cargo.toml +++ b/crates/octoscript-cli/Cargo.toml @@ -14,9 +14,11 @@ name = "octoscript" path = "src/main.rs" [dependencies] -octoscript-capabilities = { path = "../octoscript-capabilities" } +# The `octoscript` binary owns its whole dependency graph, so it keeps exact +# schema number bounds (serde_json arbitrary_precision) on. +octoscript-capabilities = { path = "../octoscript-capabilities", features = ["exact-numbers"] } octoscript-core = { path = "../octoscript-core" } -octoscript-workflow = { version = "0.1.0", path = "../octoscript-workflow" } +octoscript-workflow = { version = "0.1.0", path = "../octoscript-workflow", features = ["exact-numbers"] } [dev-dependencies] serde_json = "1.0.150" diff --git a/crates/octoscript-lsp/Cargo.toml b/crates/octoscript-lsp/Cargo.toml index a4e7c41..4db9b3f 100644 --- a/crates/octoscript-lsp/Cargo.toml +++ b/crates/octoscript-lsp/Cargo.toml @@ -16,6 +16,6 @@ serde_json = "1.0.150" octoscript-core = { path = "../octoscript-core" } [dev-dependencies] -octoscript-capabilities = { path = "../octoscript-capabilities" } -octoscript-schema = { path = "../octoscript-schema" } -octoscript-workflow = { path = "../octoscript-workflow" } +octoscript-capabilities = { path = "../octoscript-capabilities", default-features = false } +octoscript-schema = { path = "../octoscript-schema", default-features = false } +octoscript-workflow = { path = "../octoscript-workflow", default-features = false } diff --git a/crates/octoscript-media-host/Cargo.toml b/crates/octoscript-media-host/Cargo.toml index 4afe5c7..727a007 100644 --- a/crates/octoscript-media-host/Cargo.toml +++ b/crates/octoscript-media-host/Cargo.toml @@ -14,7 +14,8 @@ name = "octoscript-media-host" path = "src/main.rs" [dependencies] -octoscript-capabilities = { path = "../octoscript-capabilities", features = ["http-endpoint-catalog"] } +# Leaf binary: keeps exact schema number bounds (serde_json arbitrary_precision). +octoscript-capabilities = { path = "../octoscript-capabilities", features = ["exact-numbers", "http-endpoint-catalog"] } octoscript-core = { path = "../octoscript-core" } -octoscript-workflow = { version = "0.1.0", path = "../octoscript-workflow", features = ["http-endpoint-catalog"] } +octoscript-workflow = { version = "0.1.0", path = "../octoscript-workflow", features = ["exact-numbers", "http-endpoint-catalog"] } serde_json = "1.0.150" diff --git a/crates/octoscript-schema/Cargo.toml b/crates/octoscript-schema/Cargo.toml index 7e0aa93..978553b 100644 --- a/crates/octoscript-schema/Cargo.toml +++ b/crates/octoscript-schema/Cargo.toml @@ -10,4 +10,15 @@ description = "Bounded executable JSON-schema subset for Octoscript tools" workspace = true [dependencies] -serde_json = { version = "1.0.150", features = ["arbitrary_precision"] } +serde_json = "1.0.150" + +[features] +# Off by default. `arbitrary_precision` is a global serde_json switch: Cargo +# unifies it into every crate in the final dependency graph and it changes how +# serde_json represents `Number`, which breaks untagged/flattened numbers and +# number inspection elsewhere. Only a leaf binary that owns its whole graph +# should turn it on. See docs/schema-contracts.md#numeric-precision. +default = [] +# Compare `minimum`/`maximum` against the original JSON literal, exactly, for +# numbers beyond i64/u64/f64 precision. +exact-numbers = ["serde_json/arbitrary_precision"] diff --git a/crates/octoscript-schema/src/lib.rs b/crates/octoscript-schema/src/lib.rs index c1c8ed1..e21354d 100644 --- a/crates/octoscript-schema/src/lib.rs +++ b/crates/octoscript-schema/src/lib.rs @@ -6,6 +6,28 @@ //! `additionalProperties`, `items`, `minItems`, `maxItems`, `minLength`, //! `maxLength`, `minimum`, `maximum`, and `enum`. Annotation keywords such as //! `title` and `description` are accepted but do not affect validation. +//! +//! # Numeric precision and the `exact-numbers` feature +//! +//! `minimum` and `maximum` are compared as decimal digits times a power of +//! ten, never through a floating-point conversion. What those digits are +//! depends on how serde_json stored the number: +//! +//! - Without `exact-numbers` (the default), serde_json stores an `i64`, `u64` +//! or `f64`. Every integer in the `i64`/`u64` range and every decimal that +//! round-trips through `f64` (`0.1`, `2.5e-3`, `1e21`) compares exactly. +//! A literal that needs more precision than that is rounded to the nearest +//! `f64` when it is parsed, both in schemas and in validated values: a +//! 30-digit integer bound, or `0.30000000000000000001` versus +//! `0.30000000000000000002`, can compare equal. +//! - With `exact-numbers`, serde_json's `arbitrary_precision` keeps the +//! original literal and every finite JSON number compares exactly. +//! +//! `arbitrary_precision` is a global serde_json switch: Cargo unifies it into +//! the whole dependency graph and it changes serde_json's `Number` +//! representation for every other crate in that graph. Libraries should +//! therefore leave `exact-numbers` off and let the final binary decide; the +//! `octoscript` CLI turns it on. use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; @@ -891,6 +913,9 @@ mod tests { integer_schema.validate(&json!(1.0)).unwrap(); } + // Needs the original literal: without `exact-numbers` serde_json parses + // both sides of this comparison to the same f64. + #[cfg(feature = "exact-numbers")] #[test] fn compares_decimal_constraints_without_floating_point_rounding() { let schema = JsonSchema::compile( @@ -908,6 +933,164 @@ mod tests { assert_eq!(error.message, "number is above maximum"); } + #[cfg(feature = "exact-numbers")] + #[test] + fn compares_integer_bounds_beyond_u64_exactly() { + let schema = JsonSchema::compile( + serde_json::from_str(r#"{"type":"integer","maximum":123456789012345678901234567890}"#) + .unwrap(), + ) + .unwrap(); + schema + .validate(&serde_json::from_str("123456789012345678901234567890").unwrap()) + .unwrap(); + let error = schema + .validate(&serde_json::from_str("123456789012345678901234567891").unwrap()) + .unwrap_err(); + assert_eq!(error.message, "number is above maximum"); + } + + /// Documents what is lost without `exact-numbers`: literals that differ + /// only past f64 precision collapse to the same value. + #[cfg(not(feature = "exact-numbers"))] + #[test] + fn without_exact_numbers_bounds_use_f64_precision_beyond_integers() { + let schema = JsonSchema::compile( + serde_json::from_str(r#"{"type":"number","maximum":0.30000000000000000001}"#).unwrap(), + ) + .unwrap(); + // 0.30000000000000000002 rounds to the same f64 as the bound. + schema + .validate(&serde_json::from_str("0.30000000000000000002").unwrap()) + .unwrap(); + } + + fn parse_json(source: &str) -> Value { + serde_json::from_str(source).unwrap() + } + + #[test] + fn integer_bounds_are_inclusive_at_both_ends() { + let schema = + JsonSchema::compile(json!({"type": "integer", "minimum": -3, "maximum": 7})).unwrap(); + for allowed in [-3, 0, 7] { + schema.validate(&json!(allowed)).unwrap(); + } + assert_eq!( + schema.validate(&json!(-4)).unwrap_err().message, + "number is below minimum" + ); + assert_eq!( + schema.validate(&json!(8)).unwrap_err().message, + "number is above maximum" + ); + assert_eq!( + schema.validate(&json!(6.5)).unwrap_err().message, + "expected integer" + ); + } + + #[test] + fn integer_bounds_at_the_i64_and_u64_extremes_are_exact() { + let schema = JsonSchema::compile(json!({ + "type": "integer", + "minimum": i64::MIN, + "maximum": u64::MAX + })) + .unwrap(); + schema.validate(&json!(i64::MIN)).unwrap(); + schema.validate(&json!(u64::MAX)).unwrap(); + schema.validate(&json!(0)).unwrap(); + + let below = JsonSchema::compile(json!({"type": "integer", "minimum": i64::MIN + 1})) + .unwrap() + .validate(&json!(i64::MIN)) + .unwrap_err(); + assert_eq!(below.message, "number is below minimum"); + let above = JsonSchema::compile(json!({"type": "integer", "maximum": u64::MAX - 1})) + .unwrap() + .validate(&json!(u64::MAX)) + .unwrap_err(); + assert_eq!(above.message, "number is above maximum"); + } + + #[test] + fn decimal_bounds_compare_exactly_for_f64_representable_literals() { + let schema = JsonSchema::compile(parse_json( + r#"{"type":"number","minimum":0.1,"maximum":0.3}"#, + )) + .unwrap(); + for allowed in ["0.1", "0.10", "0.2", "0.3", "0.30"] { + schema.validate(&parse_json(allowed)).unwrap(); + } + // 0.1 + 0.2 is 0.30000000000000004 in f64, which is above 0.3. + assert_eq!( + schema.validate(&json!(0.1 + 0.2)).unwrap_err().message, + "number is above maximum" + ); + assert_eq!( + schema.validate(&parse_json("0.09999")).unwrap_err().message, + "number is below minimum" + ); + assert_eq!( + schema.validate(&parse_json("-0.2")).unwrap_err().message, + "number is below minimum" + ); + } + + #[test] + fn exponent_literals_compare_by_value() { + let schema = JsonSchema::compile(parse_json( + r#"{"type":"number","minimum":-1e-7,"maximum":1e21}"#, + )) + .unwrap(); + for allowed in [ + "1e21", + "1E+21", + "999999999999999999999", + "-1e-7", + "-0.0000001", + "0", + ] { + schema.validate(&parse_json(allowed)).unwrap(); + } + assert_eq!( + schema + .validate(&parse_json("1.0000001e21")) + .unwrap_err() + .message, + "number is above maximum" + ); + assert_eq!( + schema.validate(&parse_json("-2e-7")).unwrap_err().message, + "number is below minimum" + ); + + let integer = JsonSchema::compile(json!({"type": "integer", "maximum": 100})).unwrap(); + integer.validate(&parse_json("1e2")).unwrap(); + integer.validate(&parse_json("1.0")).unwrap(); + assert_eq!( + integer.validate(&parse_json("1.01e2")).unwrap_err().message, + "number is above maximum" + ); + assert_eq!( + integer.validate(&parse_json("1.5")).unwrap_err().message, + "expected integer" + ); + } + + #[test] + fn rejects_minimum_above_maximum_across_representations() { + assert!(matches!( + JsonSchema::compile(parse_json(r#"{"type":"number","minimum":1e1,"maximum":9.5}"#)), + Err(SchemaError::InvalidKeyword { keyword, .. }) if keyword == "minimum" + )); + JsonSchema::compile(parse_json( + r#"{"type":"number","minimum":1e1,"maximum":10}"#, + )) + .unwrap(); + } + #[test] fn rejects_schemas_larger_than_the_source_budget() { let schema = json!({"description": "x".repeat(MAX_SCHEMA_BYTES)}); diff --git a/crates/octoscript-workflow/Cargo.toml b/crates/octoscript-workflow/Cargo.toml index c9e7021..79b0fdd 100644 --- a/crates/octoscript-workflow/Cargo.toml +++ b/crates/octoscript-workflow/Cargo.toml @@ -14,14 +14,19 @@ blake3 = { version = "1.8.5", features = ["pure"] } getrandom = { version = "0.3.4", optional = true } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" -octoscript-capabilities = { path = "../octoscript-capabilities" } +octoscript-capabilities = { path = "../octoscript-capabilities", default-features = false } octoscript-core = { path = "../octoscript-core" } octoscript-protocol = { version = "0.1.0", path = "../octoscript-protocol" } octoscript-sandbox = { version = "0.1.0", path = "../octoscript-sandbox", optional = true } -octoscript-schema = { version = "0.1.0", path = "../octoscript-schema" } +octoscript-schema = { version = "0.1.0", path = "../octoscript-schema", default-features = false } octoscript-storage = { version = "0.1.0", path = "../octoscript-storage" } [features] +# Opt-in exact schema number bounds; see octoscript-schema/exact-numbers. +exact-numbers = [ + "octoscript-capabilities/exact-numbers", + "octoscript-schema/exact-numbers", +] http-endpoint-catalog = ["octoscript-capabilities/http-endpoint-catalog"] platform-keyring-secret-resolver = [ "http-endpoint-catalog", diff --git a/docs/schema-contracts.md b/docs/schema-contracts.md index f671430..83013f4 100644 --- a/docs/schema-contracts.md +++ b/docs/schema-contracts.md @@ -33,6 +33,37 @@ not general JSON Schema: `$ref`, schema composition (`allOf`, `anyOf`, The implementation limits each schema source to 32 KiB, nesting to 32 schema levels, properties per object to 128, and enum values to 128. +## Numeric precision + +`minimum` and `maximum` are compared as decimal digits times a power of ten, +never through a floating-point conversion. How many digits survive depends on +the `exact-numbers` feature of `octoscript-schema`, which turns on serde_json's +`arbitrary_precision`: + +| | Without `exact-numbers` (default) | With `exact-numbers` | +| --- | --- | --- | +| Integers in the `i64`/`u64` range | exact | exact | +| Decimals that round-trip through `f64` (`0.1`, `2.5e-3`, `1e21`) | exact | exact | +| Integers beyond `u64` (a 30-digit bound) | rounded to the nearest `f64` | exact | +| Decimals past `f64` precision (`0.30000000000000000001`) | rounded to the nearest `f64` | exact | + +Without the feature, serde_json rounds such a literal when it parses the JSON, +in the schema and in the validated value alike, so two literals that differ +only past `f64` precision compare equal. Nothing else about validation changes. + +The feature is off by default because `arbitrary_precision` is global: Cargo +unifies it into every crate in the final build and it changes serde_json's +`Number` representation for all of them, which breaks untagged or flattened +numbers and code that inspects `Number` values. Libraries (including +`octoscript-capabilities` and `octoscript-workflow`, which depend on +`octoscript-schema` with `default-features = false`) leave it off and expose +an `exact-numbers` passthrough. A leaf binary that owns its dependency graph +can turn it on; the `octoscript` CLI and `octoscript-media-host` do: + +```toml +octoscript-workflow = { version = "0.1.0", features = ["exact-numbers"] } +``` + ## Registration ```rust diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index b24ea8e..6774049 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -16,11 +16,11 @@ libfuzzer-sys = "0.4" lsp-server = "0.9.0" lsp-types = "0.97.0" serde_json = "1.0.150" -octoscript-capabilities = { version = "0.1.0", path = "../crates/octoscript-capabilities", features = ["durable-audit-journal", "http-endpoint-catalog", "json-line-worker"] } +octoscript-capabilities = { version = "0.1.0", path = "../crates/octoscript-capabilities", features = ["durable-audit-journal", "exact-numbers", "http-endpoint-catalog", "json-line-worker"] } octoscript-core = { path = "../crates/octoscript-core" } octoscript-protocol = { version = "0.1.0", path = "../crates/octoscript-protocol" } octoscript-sandbox = { version = "0.1.0", path = "../crates/octoscript-sandbox" } -octoscript-schema = { version = "0.1.0", path = "../crates/octoscript-schema" } +octoscript-schema = { version = "0.1.0", path = "../crates/octoscript-schema", features = ["exact-numbers"] } octoscript-storage = { version = "0.1.0", path = "../crates/octoscript-storage" } octoscript-worker = { version = "0.1.0", path = "../crates/octoscript-worker" } octoscript-workflow = { version = "0.1.0", path = "../crates/octoscript-workflow" }