From de63af8a137927a4f1a444c914f21ebe32149449 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:29:04 +0200 Subject: [PATCH 1/2] fix(project): reject rounded fractional schema versions --- crates/worldscript-project/src/version.rs | 101 ++++++++++-- .../worldscript-project/tests/version_test.rs | 22 ++- docs/native/CORE-MIGRATION-LEDGER.md | 2 +- features/project/projectSchemaVersion.ts | 156 +++++++++++++++++- .../project/projectSchemaVersion.test.ts | 20 ++- 5 files changed, 273 insertions(+), 28 deletions(-) diff --git a/crates/worldscript-project/src/version.rs b/crates/worldscript-project/src/version.rs index 7d7764aaa..675a4b447 100644 --- a/crates/worldscript-project/src/version.rs +++ b/crates/worldscript-project/src/version.rs @@ -386,16 +386,94 @@ fn finish_value(stack: &mut [Frame]) { } } +/// Returns whether a JSON number is mathematically a non-negative integer within the jointly-safe +/// schema-version domain, without allowing IEEE-754 rounding to redefine its grammar. +// QNBS-v3: raw-token validation keeps TS/Rust aligned for fractional literals near the safe-integer boundary. +fn is_mathematically_non_negative_integer_token(raw_token: &str) -> bool { + let (is_negative, unsigned_token) = raw_token + .strip_prefix('-') + .map_or((false, raw_token), |token| (true, token)); + let exponent_index = unsigned_token.find(['e', 'E']); + let (mantissa, exponent_text) = exponent_index.map_or((unsigned_token, ""), |index| { + (&unsigned_token[..index], &unsigned_token[index + 1..]) + }); + let decimal_index = mantissa.find('.'); + let (integer_digits, fractional_digits) = decimal_index.map_or((mantissa, ""), |index| { + (&mantissa[..index], &mantissa[index + 1..]) + }); + let mut digits = String::with_capacity(integer_digits.len() + fractional_digits.len()); + digits.push_str(integer_digits); + digits.push_str(fractional_digits); + let significant_digits = digits.trim_start_matches('0'); + if significant_digits.is_empty() { + return true; + } + if is_negative { + return false; + } + + let exponent = if exponent_text.is_empty() { + 0 + } else { + let (negative_exponent, unsigned_exponent) = + if let Some(text) = exponent_text.strip_prefix('-') { + (true, text) + } else { + ( + false, + exponent_text.strip_prefix('+').unwrap_or(exponent_text), + ) + }; + let mut magnitude = 0_i128; + for byte in unsigned_exponent.bytes() { + magnitude = magnitude + .saturating_mul(10) + .saturating_add(i128::from(byte - b'0')); + } + if negative_exponent { + -magnitude + } else { + magnitude + } + }; + let fractional_length = i128::try_from(fractional_digits.len()).unwrap_or(i128::MAX); + let decimal_shift = exponent.saturating_sub(fractional_length); + const MAX_SAFE_INTEGER_TEXT: &str = "9007199254740991"; + + let normalized_integer = if decimal_shift >= 0 { + let Ok(shift) = usize::try_from(decimal_shift) else { + return false; + }; + if significant_digits.len().saturating_add(shift) > MAX_SAFE_INTEGER_TEXT.len() { + return false; + } + format!("{}{}", significant_digits, "0".repeat(shift)) + } else { + let Ok(required_trailing_zeros) = usize::try_from(decimal_shift.unsigned_abs()) else { + return false; + }; + let trailing_zeros = significant_digits + .bytes() + .rev() + .take_while(|byte| *byte == b'0') + .count(); + if required_trailing_zeros > trailing_zeros { + return false; + } + significant_digits[..significant_digits.len() - required_trailing_zeros].to_owned() + }; + + normalized_integer.len() < MAX_SAFE_INTEGER_TEXT.len() + || (normalized_integer.len() == MAX_SAFE_INTEGER_TEXT.len() + && normalized_integer.as_str() <= MAX_SAFE_INTEGER_TEXT) +} + /// The accepted `schemaVersion` value grammar: a non-negative integer JSON number within the /// jointly-exact domain both TS (`Number`, exact only up to `2^53 - 1`) and Rust can represent -/// without rounding. Compares by numeric value, never by which `serde_json::Value` variant parsed -/// it as — JS's `JSON.parse` collapses `1` and `1.0` into the same number -/// (`Number.isInteger(1.0)` is `true`), so an integer-valued decimal literal like `1.0` must -/// classify identically here, not be rejected merely because `serde_json` parses a decimal-point -/// literal as its `Float` variant. A value outside `[0, 2^53 - 1]` is `Malformed`: beyond that -/// bound, `f64` (and therefore JS's `Number`) can no longer represent every integer exactly, so -/// TS and Rust could silently disagree on the value — this is a joint admission-domain limit for -/// the `schemaVersion` discriminant specifically, not a statement about opaque numeric fields +/// without rounding. A value outside `[0, 2^53 - 1]` is `Malformed`: beyond that bound, `f64` +/// (and therefore JS's `Number`) can no longer represent every integer exactly, so TS and Rust +/// could silently disagree on the value — this is a joint admission-domain limit for the +/// `schemaVersion` discriminant specifically, not a statement about opaque numeric fields /// elsewhere in the document. fn validated_schema_version_number( sighting: &SchemaVersionSighting, @@ -404,8 +482,11 @@ fn validated_schema_version_number( let SchemaVersionSighting::Scalar(start, end) = sighting else { return None; }; - // QNBS-v3: parses the raw token text directly via Rust's std f64 parser (verified to match V8's rounding exactly) rather than serde_json::Value::as_f64, whose own number handling rounds some large decimal literals differently and broke parity at the safe-integer boundary. - let numeric: f64 = raw_text[*start..*end].parse().ok()?; + let raw_token = &raw_text[*start..*end]; + if !is_mathematically_non_negative_integer_token(raw_token) { + return None; + } + let numeric: f64 = raw_token.parse().ok()?; const MAX_SAFE_INTEGER: f64 = 9007199254740991.0; // 2^53 - 1 if !numeric.is_finite() || numeric.fract() != 0.0 diff --git a/crates/worldscript-project/tests/version_test.rs b/crates/worldscript-project/tests/version_test.rs index 200d355e5..4541db033 100644 --- a/crates/worldscript-project/tests/version_test.rs +++ b/crates/worldscript-project/tests/version_test.rs @@ -59,12 +59,16 @@ fn unregistered_lower_version_is_unsupported_older() { #[test] fn integer_valued_float_literal_matches_ts_json_parse_semantics() { - // QNBS-v3: JS's JSON.parse collapses 1 and 1.0 into the same number; serde_json parses "1.0" as its Float variant, so this must compare by value, not representation, for TS/Rust parity. - let raw = format!(r#"{{"schemaVersion": {CURRENT_PROJECT_SCHEMA_VERSION}.0}}"#); - assert_eq!( - classify_raw_project_version(&raw), - ProjectVersionClassification::Current - ); + // QNBS-v3: mathematically integer decimal/exponent spellings remain valid after raw-token validation. + for raw in [ + format!(r#"{{"schemaVersion": {CURRENT_PROJECT_SCHEMA_VERSION}.0}}"#), + format!(r#"{{"schemaVersion": {CURRENT_PROJECT_SCHEMA_VERSION}e0}}"#), + ] { + assert_eq!( + classify_raw_project_version(&raw), + ProjectVersionClassification::Current + ); + } } #[test] @@ -252,12 +256,12 @@ fn schema_version_at_the_js_safe_integer_boundary_is_accepted() { } #[test] -fn fractional_literal_rounding_near_the_boundary_matches_v8_not_serde_json_value() { - // QNBS-v3: serde_json::Value::as_f64() rounds "9007199254740991.4" to 9007199254740992 (rejected), while V8's Number() and Rust's std str::parse:: both round it to 9007199254740991 (accepted) - the fix parses the raw token directly to avoid this specific serde_json rounding divergence. +fn fractional_literal_near_the_boundary_is_malformed_before_rounding() { + // QNBS-v3: mathematical integer validation must happen before f64 rounding can make this token look integral. let raw = r#"{"schemaVersion": 9007199254740991.4}"#; assert_eq!( classify_raw_project_version(raw), - ProjectVersionClassification::Future + ProjectVersionClassification::Malformed ); } diff --git a/docs/native/CORE-MIGRATION-LEDGER.md b/docs/native/CORE-MIGRATION-LEDGER.md index abb86716a..3abafa684 100644 --- a/docs/native/CORE-MIGRATION-LEDGER.md +++ b/docs/native/CORE-MIGRATION-LEDGER.md @@ -16,7 +16,7 @@ scope shifts — it is a living decision record, not a one-time snapshot. | 6 | Storage — IDB backend (all encryption) | TS, `services/storage/` (18 files, 5,363 lines) — mature AES-256-GCM, ADR-0018 "Accepted and implemented" | High — IndexedDB is browser-only, not portable to Qt/GPUI as-is | High, mature | High | Medium | Low as literally written; high as a design reference | **Deferred to Wave 3-4** | None in Wave 2. ADR-0018's 6 invariants are required reading for Wave 3 crypto design | None in Wave 2 | | 7 | `features/project/` domain logic | TS, `features/project/` (24 files, 2,114 lines) — real logic concentrated in `thunks/` + `projectSelectors.ts` (~450-500 lines); `reducers/` (11 files) is CRUD bookkeeping | High — Redux-store-shape/dispatch bound; `reducers/` stays TS-side permanently | Low | Medium (import/restore orchestration) | Low-medium | Medium (only the thunks/selectors subset) | Deferred | Candidate after the schema crate is proven; only thunks/selectors, never `reducers/` | Not started | | 8 | AI services | TS, `services/ai/` (44 files, 5,401 lines), mixed portability (retry/routing/error-taxonomy renderer-neutral vs. `computeShaderFactory.ts`/`webGpuDetectorService.ts`/`.wgsl` inherently WebGPU-coupled) | Mixed | Medium-high (API keys) | Low-medium | Medium | Uncertain — too large/mixed to assess narrowly | **Out of scope for all of Wave 2** | None proposed | None | -| 9 | Project state-shape compatibility adapter | TS, `features/project/coreBoundaryAdapter.ts` at the Core boundary + Rust, `crates/worldscript-project` schema | High at the boundary — production Redux `EntityState` must be translated without importing Redux into Core | Low | High — ID/order preservation is part of project identity | Medium | High — every native renderer needs the same conversion contract | **2 — Wave 2 prerequisite before G1 evaluation** | **In progress — typed adapter, fixtures, and the first observation-only desktop shadow caller are locally proven; no authority switch**; normalizes array or Redux `EntityState` to renderer-neutral arrays and reconstructs the TS-side shape only at the integration boundary. The Rust verdict is partial because unknown fields are not rejected, and the envelope's `schemaVersion` is synthesized rather than persisted. Both required decisions (persisted version authority; a field-class-staged unknown-field policy, not one global policy) are resolved and maintainer-admitted in [`docs/native/PROJECT-CORE-COMPATIBILITY-CONTRACT.md`](PROJECT-CORE-COMPATIBILITY-CONTRACT.md), `PROPOSED = YES` / `ADMITTED = YES` / `IMPLEMENTATION_STARTED = NO` — issue #553 remains open until the admitted contract is implemented (Slices A–F). | `tests/unit/features/project/coreBoundaryAdapter.test.ts` covers array and `EntityState` inputs, round-trip ID/order preservation, and rejection of duplicate IDs, missing references, and orphaned entities for both characters and worlds; the envelope fixture is accepted by Rust after migration and validation | +| 9 | Project state-shape compatibility adapter | TS, `features/project/coreBoundaryAdapter.ts` at the Core boundary + Rust, `crates/worldscript-project` schema | High at the boundary — production Redux `EntityState` must be translated without importing Redux into Core | Low | High — ID/order preservation is part of project identity | Medium | High — every native renderer needs the same conversion contract | **2 — Wave 2 prerequisite before G1 evaluation** | **In progress — `IMPLEMENTATION_STARTED = YES`; Slice A's persisted-version classification and TS/Rust parity (including raw-token grammar) are implemented, and Slice B's first observation-only IDB ingress is wired; no authority switch**; normalizes array or Redux `EntityState` to renderer-neutral arrays and reconstructs the TS-side shape only at the integration boundary. The Rust verdict remains partial because unknown fields are not rejected and no canonical raw carrier exists yet. Both required decisions (persisted version authority; a field-class-staged unknown-field policy, not one global policy) are resolved and maintainer-admitted in [`docs/native/PROJECT-CORE-COMPATIBILITY-CONTRACT.md`](PROJECT-CORE-COMPATIBILITY-CONTRACT.md), `PROPOSED = YES` / `ADMITTED = YES` — issue #553 remains open until the admitted contract is implemented (Slices A–F). | `tests/unit/features/project/coreBoundaryAdapter.test.ts` covers array and `EntityState` inputs, round-trip ID/order preservation, and rejection of duplicate IDs, missing references, and orphaned entities for both characters and worlds; `tests/unit/features/project/projectSchemaVersion.test.ts` and `crates/worldscript-project/tests/version_test.rs` cover classification/parity; the IDB load observation is covered by `tests/unit/services/storage/idbProjectStoreLoadStateObservation.test.ts`; the envelope fixture is accepted by Rust after migration and validation | | 10 | R-15 protected desktop storage contract | **Design only (S5-A baseline)**, `docs/native/R15-SECURE-STORAGE-CONTRACT.md`; current desktop records remain TS/Tauri filesystem authority | High — future Core must serve Tauri and Qt without renderer-private crypto semantics | High | High — durability, migration, and identity binding protect user data | High | **Highest — cross-renderer security/durability contract** | **3 — S5-A, S5-B1, S5-B2, and S5-B3 all admitted; final cross-contract audit complete, S5_TERMINAL pending this PR's own merge** | **S5_A_ADMITTED=YES / S5_B1_ADMITTED=YES / S5_B2_ADMITTED=YES / S5_B3_ADMITTED=YES / S5_IMPLEMENTATION_READY=NO / S5_TERMINAL=NO (pending)**; inventory, identity/AAD envelope, key epochs, fail-closed reads, durable replacement, crash-resumable migration, unified admission, race-free `AuthoritySnapshot` acquisition/lifetime (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md`), canonical migration source/payload evidence (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md`), and the chunked large-object envelope (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md`) are all specified. No production authority switch or plaintext migration is claimed. | Final S5 cross-contract consistency audit (mutual reference integrity across all four documents) is complete — two mechanical citation-drift notes (a stale disposition-count note in §10.4.1, and S5-B3's mis-citation of S5-B1's migration-time mechanism for its own ordinary-write staging debris) and three substantive gaps were corrected: S5-B3's chunk-locator carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current one; §10.4.1's atomic-write-temporary-files carve-out contradicted its own "exactly one of three groups" exhaustiveness claim; and fixing that carve-out into an explicit `REFUSE_AUTHORITY_SWITCH` group in turn made Gate 7's class-level rule permanently unsatisfiable for that one class, fixed by making Gate 7 instance-aware. `S5_TERMINAL` is set to YES only in a dedicated follow-up commit once this PR merges and its own post-merge main CI (incl. CodeQL) is confirmed green. Headless Core vectors, fault-injection tests, per-record migration tests, packaged durability evidence, and explicit #357/#359/#360/#361 reconciliation are still required before implementation gates can close | ## Decisions this table records diff --git a/features/project/projectSchemaVersion.ts b/features/project/projectSchemaVersion.ts index 4be63c609..c2a94f1ff 100644 --- a/features/project/projectSchemaVersion.ts +++ b/features/project/projectSchemaVersion.ts @@ -71,6 +71,90 @@ function decodeJsonStringLiteral(literalWithQuotes: string): string | null { } } +function isJsonDigit(ch: string | undefined): boolean { + return ch !== undefined && ch >= '0' && ch <= '9'; +} + +function skipJsonIntegerPart(rawText: string, start: number): number { + let i = start; + if (rawText[i] === '-') i++; + if (rawText[i] === '0') { + i++; + } else { + while (isJsonDigit(rawText[i])) i++; + } + return i; +} + +function skipJsonFractionPart(rawText: string, start: number): number { + let i = start; + if (rawText[i] === '.') { + i++; + while (isJsonDigit(rawText[i])) i++; + } + return i; +} + +function skipJsonExponentPart(rawText: string, start: number): number { + let i = start; + if (rawText[i] === 'e' || rawText[i] === 'E') { + i++; + if (rawText[i] === '+' || rawText[i] === '-') i++; + while (isJsonDigit(rawText[i])) i++; + } + return i; +} + +/** Skips one already-validated JSON number and returns the index after its token. */ +function skipJsonNumberLiteral(rawText: string, start: number): number { + const afterInteger = skipJsonIntegerPart(rawText, start); + const afterFraction = skipJsonFractionPart(rawText, afterInteger); + return skipJsonExponentPart(rawText, afterFraction); +} + +function updateJsonDepth(ch: string | undefined, depth: number): number { + if (ch === '{' || ch === '[') return depth + 1; + if (ch === '}' || ch === ']') return depth - 1; + return depth; +} + +function readTopLevelSchemaVersionNumberToken( + rawText: string, + stringStart: number, + stringEnd: number, +): string | null { + let valueStart = stringEnd; + while (valueStart < rawText.length && /\s/.test(rawText[valueStart] ?? '')) valueStart++; + if (rawText[valueStart] !== ':') return null; + const decoded = decodeJsonStringLiteral(rawText.slice(stringStart, stringEnd)); + if (decoded !== 'schemaVersion') return null; + valueStart++; + while (valueStart < rawText.length && /\s/.test(rawText[valueStart] ?? '')) valueStart++; + if (rawText[valueStart] !== '-' && !isJsonDigit(rawText[valueStart])) return null; + return rawText.slice(valueStart, skipJsonNumberLiteral(rawText, valueStart)); +} + +/** Returns the top-level schemaVersion number token without applying IEEE-754 rounding. */ +function findTopLevelSchemaVersionNumberToken(rawText: string): string | null { + let depth = 0; + let i = 0; + while (i < rawText.length) { + const ch = rawText[i]; + if (ch === '"') { + const stringStart = i; + i = skipJsonStringLiteral(rawText, i); + if (depth === 1) { + const token = readTopLevelSchemaVersionNumberToken(rawText, stringStart, i); + if (token !== null) return token; + } + continue; + } + depth = updateJsonDepth(ch, depth); + i++; + } + return null; +} + /** * Yields the *decoded* name of every JSON object key at the outermost (depth-1) object level of * `rawText`, in source order — decoded per JSON string-escape rules, not raw source spelling, so @@ -137,6 +221,69 @@ function tryParseJsonObject(rawText: string): Record | null { return parsed as Record; } +type RawSchemaVersionNumber = { + isNegative: boolean; + significantDigits: string; + fractionalDigitsLength: number; + exponent: number; +}; + +function parseRawSchemaVersionNumber(token: string): RawSchemaVersionNumber { + const isNegative = token.startsWith('-'); + const unsignedToken = isNegative ? token.slice(1) : token; + const exponentIndex = unsignedToken.search(/[eE]/); + const mantissa = exponentIndex === -1 ? unsignedToken : unsignedToken.slice(0, exponentIndex); + const exponentText = exponentIndex === -1 ? '' : unsignedToken.slice(exponentIndex + 1); + const decimalIndex = mantissa.indexOf('.'); + const integerDigits = decimalIndex === -1 ? mantissa : mantissa.slice(0, decimalIndex); + const fractionalDigits = decimalIndex === -1 ? '' : mantissa.slice(decimalIndex + 1); + return { + isNegative, + significantDigits: `${integerDigits}${fractionalDigits}`.replace(/^0+/, ''), + fractionalDigitsLength: fractionalDigits.length, + exponent: exponentText === '' ? 0 : Number(exponentText), + }; +} + +function normalizeRawSchemaVersionInteger(parts: RawSchemaVersionNumber): string | null { + const decimalShift = parts.exponent - parts.fractionalDigitsLength; + const maxSafeIntegerText = String(Number.MAX_SAFE_INTEGER); + + if (decimalShift >= 0) { + if ( + !Number.isFinite(decimalShift) || + parts.significantDigits.length + decimalShift > maxSafeIntegerText.length + ) { + return null; + } + return `${parts.significantDigits}${'0'.repeat(decimalShift)}`; + } + if (!Number.isFinite(decimalShift)) return null; + const requiredTrailingZeros = -decimalShift; + const trailingZeros = + parts.significantDigits.length - parts.significantDigits.replace(/0+$/, '').length; + if (requiredTrailingZeros > trailingZeros) return null; + return parts.significantDigits.slice(0, parts.significantDigits.length - requiredTrailingZeros); +} + +function isWithinSchemaVersionSafeIntegerDomain(normalizedInteger: string): boolean { + const maxSafeIntegerText = String(Number.MAX_SAFE_INTEGER); + return ( + normalizedInteger.length < maxSafeIntegerText.length || + (normalizedInteger.length === maxSafeIntegerText.length && + normalizedInteger <= maxSafeIntegerText) + ); +} + +// QNBS-v3: reject mathematically fractional raw tokens before IEEE-754 rounding can make them look integral. +function isMathematicallyNonNegativeIntegerToken(token: string): boolean { + const parts = parseRawSchemaVersionNumber(token); + if (parts.significantDigits.length === 0) return true; + if (parts.isNegative) return false; + const normalizedInteger = normalizeRawSchemaVersionInteger(parts); + return normalizedInteger !== null && isWithinSchemaVersionSafeIntegerDomain(normalizedInteger); +} + /** * The accepted `schemaVersion` value grammar: a non-negative integer JSON number within the * jointly-exact domain both TS (`Number`, exact only up to `Number.MAX_SAFE_INTEGER`) and Rust @@ -181,7 +328,14 @@ export function classifyRawProjectVersion(rawText: string): ProjectVersionClassi if (!Object.hasOwn(record, 'schemaVersion')) return 'LEGACY_UNVERSIONED'; const value = record['schemaVersion']; - if (!isValidSchemaVersionValue(value)) return 'MALFORMED'; + const rawToken = findTopLevelSchemaVersionNumberToken(rawText); + if ( + !isValidSchemaVersionValue(value) || + rawToken === null || + !isMathematicallyNonNegativeIntegerToken(rawToken) + ) { + return 'MALFORMED'; + } return classifyVersionNumber(value); } diff --git a/tests/unit/features/project/projectSchemaVersion.test.ts b/tests/unit/features/project/projectSchemaVersion.test.ts index 805a6589b..216b68c2b 100644 --- a/tests/unit/features/project/projectSchemaVersion.test.ts +++ b/tests/unit/features/project/projectSchemaVersion.test.ts @@ -55,12 +55,13 @@ describe('classifyRawProjectVersion', () => { expect(classifyRawProjectVersion(raw)).toBe('FUTURE'); }); - it('classifies an integer-valued decimal literal (e.g. "1.0") the same as its integer form', () => { - // QNBS-v3: JS's JSON.parse collapses 1 and 1.0 into the same number; the Rust side must match this exactly (serde_json otherwise parses "1.0" as a distinct Float variant). - expect( - classifyRawProjectVersion(`{"schemaVersion": ${CURRENT_PROJECT_SCHEMA_VERSION}.0}`), - ).toBe('CURRENT'); - }); + it.each([`${CURRENT_PROJECT_SCHEMA_VERSION}.0`, `${CURRENT_PROJECT_SCHEMA_VERSION}e0`])( + 'classifies an integer-valued JSON number literal (%s) the same as its integer form', + (literal) => { + // QNBS-v3: mathematically integer decimal/exponent spellings remain valid after raw-token validation. + expect(classifyRawProjectVersion(`{"schemaVersion": ${literal}}`)).toBe('CURRENT'); + }, + ); it('classifies an unregistered lower version as UNSUPPORTED_OLDER', () => { // QNBS-v3: no SUPPORTED_OLDER_SOURCE_VERSIONS entry exists yet since PROJECT_SCHEMA_V1 is the only defined version. @@ -121,11 +122,16 @@ describe('classifyRawProjectVersion', () => { expect(classifyRawProjectVersion('null')).toBe('MALFORMED'); }); - it('accepts schemaVersion at the JS-safe-integer boundary (2^53 - 1)', () => { + it('accepts an integer schemaVersion at the JS-safe-integer boundary (2^53 - 1)', () => { // QNBS-v3: the largest integer both f64/JS Number and Rust can represent exactly - the joint admission-domain ceiling. expect(classifyRawProjectVersion('{"schemaVersion": 9007199254740991}')).toBe('FUTURE'); }); + it('rejects a fractional raw token that rounds to the JS-safe-integer boundary', () => { + // QNBS-v3: mathematical integer validation must happen before JSON.parse rounds this token to 9007199254740991. + expect(classifyRawProjectVersion('{"schemaVersion": 9007199254740991.4}')).toBe('MALFORMED'); + }); + it('classifies schemaVersion one past the JS-safe-integer boundary as MALFORMED', () => { // QNBS-v3: beyond 2^53-1, f64/Number can no longer represent every integer exactly, so TS and Rust could silently disagree - reject rather than risk divergence. expect(classifyRawProjectVersion('{"schemaVersion": 9007199254740992}')).toBe('MALFORMED'); From ced5721f8ac654a558498d0e2c1ce9a50161fd94 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:47:52 +0200 Subject: [PATCH 2/2] fix(project): reject non-number schema version tokens --- crates/worldscript-project/src/version.rs | 6 +++++- crates/worldscript-project/tests/version_test.rs | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/worldscript-project/src/version.rs b/crates/worldscript-project/src/version.rs index 675a4b447..455bc03e0 100644 --- a/crates/worldscript-project/src/version.rs +++ b/crates/worldscript-project/src/version.rs @@ -483,7 +483,11 @@ fn validated_schema_version_number( return None; }; let raw_token = &raw_text[*start..*end]; - if !is_mathematically_non_negative_integer_token(raw_token) { + let token_bytes = raw_token.as_bytes(); + // QNBS-v3: validate the captured JSON token before grammar-specific arithmetic can inspect it. + if validate_json_number(token_bytes, 0) != Some(token_bytes.len()) + || !is_mathematically_non_negative_integer_token(raw_token) + { return None; } let numeric: f64 = raw_token.parse().ok()?; diff --git a/crates/worldscript-project/tests/version_test.rs b/crates/worldscript-project/tests/version_test.rs index 4541db033..d5056722b 100644 --- a/crates/worldscript-project/tests/version_test.rs +++ b/crates/worldscript-project/tests/version_test.rs @@ -75,6 +75,7 @@ fn integer_valued_float_literal_matches_ts_json_parse_semantics() { fn present_invalid_values_are_malformed() { for raw in [ r#"{"schemaVersion": "1"}"#, + r#"{"schemaVersion": "1e"}"#, r#"{"schemaVersion": null}"#, r#"{"schemaVersion": 1.5}"#, r#"{"schemaVersion": -1}"#,