Skip to content

forms: carry int64-sized bounds as exact strings, so the client gate actually closes - #259

Open
Yaraslaut wants to merge 3 commits into
masterfrom
fix/213-exact-numeric-bounds
Open

forms: carry int64-sized bounds as exact strings, so the client gate actually closes#259
Yaraslaut wants to merge 3 commits into
masterfrom
fix/213-exact-numeric-bounds

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #213.

The defect

DynamicForm's integer gate compared the typed value against minimum/maximum
read straight from the parsed schema. Those are JSON numbers, and every shipped
app hands the renderer its schema via JSON.parse(controller.schemasJson) — so a
bound above 2^53 is already an IEEE-754 double by the time the gate sees it:

schema maximum for an int64_t field: 9223372036854775807
       after JSON.parse:             9223372036854775808   (rounded up)

Comparing INT64_MAX + 1 against that judges it equal, not greater, so the
renderer's own validation admitted a value the schema excludes. Nothing is
corrupted — the payload keeps exact digits and the server rejects it with
parse_number_failure — but the client called a value valid that never was.

mergeSchemaExtras already reads the schema in u64 number mode precisely so
these bounds are not rounded on the C++ side, with a comment saying so. The
renderer half was never done, because the rounding happens after C++ hands the
text over.

The fix

schemaJson<A>() also emits each oversized bound as an exact decimal string
x-exactMinimum / x-exactMaximum — which JSON.parse cannot round. The
integer gate prefers it and compares digits directly, since no JS number can hold
the bound.

"int64_t": {
  "type": "integer",
  "minimum": -9223372036854775808,
  "maximum": 9223372036854775807,
  "x-exactMinimum": "-9223372036854775808",
  "x-exactMaximum": "9223372036854775807"
}

Additive and narrow. minimum/maximum are untouched, so a renderer that
ignores the new keys behaves exactly as before (per the spec's versioning
stance). And they are emitted only above 2^53 — an int32_t field, a
Ranged slider, a hand-written maximum: 10 lose nothing to a double, and their
schemas do not change by a byte. Only $defs/int64_t and $defs/uint64_t pick
up companions.

Why they aren't called x-minimumText

That was the first name, and it broke a test — usefully. x-min and x-max
already exist as slider bounds, and test_widget_hints.cpp's
PlainFieldsEmitNoWidgetHint asserts their absence with a bare substring
check, which x-minimumText satisfied. The failure pointed at slider bounds for
a change that had nothing to do with them — and the prefix collision would have
been just as confusing to a human reader.

Renamed, and that assertion is tightened to match JSON keys ("x-min":) rather
than substrings, so the next key sharing a prefix doesn't fail an unrelated test.

Verification

The issue flags invariant 7 and it is the crux: no existing fixture went near
INT64_MAX
, and one with small bounds passes whether or not the bug exists. So
every new QML fixture sits at the boundary — INT64_MAX/INT64_MIN accepted
and exact, one past each rejected, leading zeros handled, and the numeric
fallback still gating where no companion is emitted.

Mutation-checked in both halves, independently:

mutation result
remove the C++ emitter call 3 of 4 emitter cases fail
make the gate ignore the exact bound test_int64_max_plus_one_is_rejected fails
check result
morph_tests 20632 assertions in 1158 cases, all passed
QML suite 125 passed, 0 failed
Doxygen (WARN_AS_ERROR) clean
scripts/check_spec_citations.sh pass

Interaction with #253

#253 (fix for #189) makes resolveProp resolve through anyOf, so a bare
std::optional<std::int64_t> becomes a typed integer field and inherits
$defs/int64_t's bounds — and would inherit this defect too. I measured that on
the #253 branch and recorded it on #213. Because both paths converge in
resolveProp, this fix covers the anyOf path automatically once #253 lands;
nothing further is needed. The two branches touch nearby lines in
DynamicForm.qml, so whichever merges second may need a trivial rebase.

…actually closes

DynamicForm's numeric gate compared a typed value against `minimum`/`maximum`
read straight from the parsed schema. Those are JSON numbers, and every shipped
app hands the renderer its schema via `JSON.parse(controller.schemasJson)`, so a
bound above 2^53 is already an IEEE-754 double by the time the gate sees it:
INT64_MAX arrives as 9223372036854775808. Comparing INT64_MAX + 1 against that
judges it "not greater", so the renderer's own validation admitted a value the
schema excludes. Nothing was corrupted -- the payload keeps exact digits and the
server rejects it with parse_number_failure -- but the client called a value
valid that never was.

`mergeSchemaExtras` already reads the schema in u64 number mode precisely so
these bounds are not rounded on the C++ side; the comment saying so is right
there. The renderer half was never done, because the rounding happens after C++
hands the text over.

So schemaJson<A>() now also emits the bound as an exact decimal *string* --
`x-exactMinimum`/`x-exactMaximum` -- which JSON.parse cannot round, and the
integer gate prefers it, comparing digits directly since no JS number can hold
the bound. Both keys are additive: `minimum`/`maximum` are untouched, so a
renderer ignoring them behaves exactly as before.

Emitted only above 2^53. An int32_t field, a Ranged slider, a hand-written
`maximum: 10` -- none loses anything to a double, and none of their schemas
change by a byte. Only $defs/int64_t and $defs/uint64_t pick up the companion.

Named x-exactMinimum, not x-minimumText, because of what the first name broke:
`x-min` and `x-max` already exist as *slider* bounds, and
test_widget_hints.cpp's PlainFieldsEmitNoWidgetHint asserts their absence with a
bare substring check -- which `x-minimumText` satisfied, failing that test with a
message about slider bounds. The prefix collision would have been just as
confusing to a human reader. That assertion is tightened here to match JSON keys
(`"x-min":`) rather than substrings, so the next key sharing a prefix does not
fail a test that has nothing to do with it.

Verification. Mutation-checked in both halves independently: removing the C++
emitter fails 3 of the 4 emitter cases, and making the gate ignore the exact
bound fails test_int64_max_plus_one_is_rejected. Per the issue's invariant-7
note, no existing fixture went near INT64_MAX -- one with small bounds passes
whether or not the bug exists -- so every new QML fixture sits at the boundary:
INT64_MAX/INT64_MIN accepted and exact, +1/-1 past each rejected, leading zeros
handled, and the numeric fallback still gating where no companion is emitted.

morph_tests 20632 assertions / 1158 cases; QML 125/0; Doxygen clean.

Closes #213
CI runs clang-tidy-diff with -warnings-as-errors=* over changed lines, which
#258 tripped; reproducing it locally against the same clang 22 the workflow
pins turned up the same class of findings here, before CI got to them.

Fixed rather than suppressed where the check was right:

- modernize-use-integer-sign-comparison: the two bounds arrive in different
  signednesses, so the casts were exactly the sign-mismatch the comparison
  exists to get right. Now std::cmp_greater/std::cmp_less, with a signed twin
  of the 2^53 limit for the negative side.
- The fixture models gain real state, so execute() genuinely needs `this` and
  is neither static-able nor const-able -- rather than NOLINTing
  readability-convert-member-functions-to-static.
- readability-container-contains: C++23 std::string::contains.

Suppressed only where the pattern is forced:

- misc-use-internal-linkage on the fixtures -- glaze's reflection takes the
  address of an `extern const T`, so the anonymous namespace the check asks for
  is exactly what they cannot have. Same suppression tests/
  test_shared_instances.cpp already uses.
- cert-err58-cpp / bugprone-throwing-static-initialization /
  misc-const-correctness across the BRIDGE_REGISTER_* expansions: every test
  that registers a model has this shape.
- cppcoreguidelines-pro-bounds-avoid-unchecked-container-access on the glaze
  DOM, the same NOLINT the surrounding header already carries.
- misc-no-recursion on annotateExactNumericBounds -- walking a JSON tree is
  inherently recursive; that is the function.

No behaviour change: 11 assertions / 4 cases unchanged, framework and QML
suites green.
The original run was cancelled by the supersede-obsolete-runs concurrency rule
(#257) shortly after it merged, and re-running the cancelled workflows produced
attempts that were themselves cancelled within minutes. An empty commit gives
the PR a fresh head so its checks run from a clean slate.

No content change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DynamicForm's numeric bounds check compares against a JSON.parse-rounded minimum/maximum, letting INT64_MAX+1 through the client gate

1 participant