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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion crates/octoscript-capabilities/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
6 changes: 4 additions & 2 deletions crates/octoscript-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 3 additions & 3 deletions crates/octoscript-lsp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
5 changes: 3 additions & 2 deletions crates/octoscript-media-host/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
13 changes: 12 additions & 1 deletion crates/octoscript-schema/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
183 changes: 183 additions & 0 deletions crates/octoscript-schema/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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(
Expand All @@ -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)});
Expand Down
9 changes: 7 additions & 2 deletions crates/octoscript-workflow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions docs/schema-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading