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
105 changes: 95 additions & 10 deletions crates/worldscript-project/src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
Expand All @@ -404,8 +482,15 @@ 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];
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()?;
const MAX_SAFE_INTEGER: f64 = 9007199254740991.0; // 2^53 - 1
if !numeric.is_finite()
|| numeric.fract() != 0.0
Expand Down
23 changes: 14 additions & 9 deletions crates/worldscript-project/tests/version_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,23 @@ 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]
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}"#,
Expand Down Expand Up @@ -252,12 +257,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::<f64> 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
);
}

Expand Down
2 changes: 1 addition & 1 deletion docs/native/CORE-MIGRATION-LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading