diff --git a/crates/core/proptest-regressions/host/v8/to_value.txt b/crates/core/proptest-regressions/host/v8/to_value.txt new file mode 100644 index 00000000000..bdd01dd63f5 --- /dev/null +++ b/crates/core/proptest-regressions/host/v8/to_value.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b9cebcbfdb32c25a2093f9e860502a771b4fbd22f92cc7a6a72032aed46dd476 # shrinks to x = -9223372036854775809 +cc 2480a43bc9d76592946409dbe626d75ddaa94c6f1506e6508e9e4c688ef36cec # shrinks to x = -340282366920938463463374607431768211456 diff --git a/crates/core/src/host/v8/error.rs b/crates/core/src/host/v8/error.rs new file mode 100644 index 00000000000..7142f1b0683 --- /dev/null +++ b/crates/core/src/host/v8/error.rs @@ -0,0 +1,35 @@ +//! Utilities for error handling when dealing with V8. + +use v8::{Exception, HandleScope, Local, Value}; + +/// The result of trying to convert a [`Value`] in scope `'s` to some type `T`. +pub(super) type ValueResult<'s, T> = Result>; + +/// Types that can convert into a JS string type. +pub(super) trait IntoJsString { + /// Converts `self` into a JS string. + fn into_string<'s>(self, scope: &mut HandleScope<'s>) -> Local<'s, v8::String>; +} + +impl IntoJsString for String { + fn into_string<'s>(self, scope: &mut HandleScope<'s>) -> Local<'s, v8::String> { + v8::String::new(scope, &self).unwrap() + } +} + +/// Error types that can convert into JS exception values. +pub(super) trait IntoException { + /// Converts `self` into a JS exception value. + fn into_exception<'s>(self, scope: &mut HandleScope<'s>) -> Local<'s, Value>; +} + +/// A type converting into a JS `TypeError` exception. +#[derive(Copy, Clone)] +pub struct TypeError(pub M); + +impl IntoException for TypeError { + fn into_exception<'s>(self, scope: &mut HandleScope<'s>) -> Local<'s, Value> { + let msg = self.0.into_string(scope); + Exception::type_error(scope, msg) + } +} diff --git a/crates/core/src/host/v8/from_value.rs b/crates/core/src/host/v8/from_value.rs new file mode 100644 index 00000000000..ecf90024484 --- /dev/null +++ b/crates/core/src/host/v8/from_value.rs @@ -0,0 +1,189 @@ +#![allow(dead_code)] + +use super::error::{IntoException as _, TypeError, ValueResult}; +use bytemuck::{AnyBitPattern, NoUninit}; +use spacetimedb_sats::{i256, u256}; +use v8::{BigInt, Boolean, HandleScope, Int32, Local, Number, Uint32, Value}; + +/// Types that a v8 [`Value`] can be converted into. +pub(super) trait FromValue: Sized { + /// Converts `val` in `scope` to `Self` if possible. + fn from_value<'s>(val: Local<'_, Value>, scope: &mut HandleScope<'s>) -> ValueResult<'s, Self>; +} + +/// Provides a [`FromValue`] implementation. +macro_rules! impl_from_value { + ($ty:ty, ($val:ident, $scope:ident) => $logic:expr) => { + impl FromValue for $ty { + fn from_value<'s>($val: Local<'_, Value>, $scope: &mut HandleScope<'s>) -> ValueResult<'s, Self> { + $logic + } + } + }; +} + +/// Tries to cast `Value` into `T` or raises a JS exception as a returned `Err` value. +fn try_cast<'a, 'b, T>( + scope: &mut HandleScope<'a>, + val: Local<'b, Value>, + on_err: impl FnOnce(&str) -> String, +) -> ValueResult<'a, Local<'b, T>> +where + Local<'b, T>: TryFrom>, +{ + val.try_cast::() + .map_err(|_| TypeError(on_err(val.type_repr())).into_exception(scope)) +} + +/// Tries to cast `Value` into `T` or raises a JS exception as a returned `Err` value. +macro_rules! cast { + ($scope:expr, $val:expr, $js_ty:ty, $expected:literal $(, $args:expr)* $(,)?) => {{ + try_cast::<$js_ty>($scope, $val, |got| format!(concat!("Expected ", $expected, ", got {__got}"), $($args,)* __got = got)) + }}; +} +pub(super) use cast; + +/// Returns a JS exception value indicating that a value overflowed +/// when converting to the type `rust_ty`. +fn value_overflowed<'s>(rust_ty: &str, scope: &mut HandleScope<'s>) -> Local<'s, Value> { + TypeError(format!("Value overflowed `{rust_ty}`")).into_exception(scope) +} + +/// Returns a JS exception value indicating that a value underflowed +/// when converting to the type `rust_ty`. +fn value_underflowed<'s>(rust_ty: &str, scope: &mut HandleScope<'s>) -> Local<'s, Value> { + TypeError(format!("Value underflowed `{rust_ty}`")).into_exception(scope) +} + +// `FromValue for bool`. +impl_from_value!(bool, (val, scope) => cast!(scope, val, Boolean, "boolean").map(|b| b.is_true())); + +// `FromValue for u8, u16, u32, i8, i16, i32`. +macro_rules! int32_from_value { + ($js_ty:ty, $rust_ty:ty) => { + impl_from_value!($rust_ty, (val, scope) => { + let num = cast!(scope, val, $js_ty, "number for `{}`", stringify!($rust_ty))?; + num.value().try_into().map_err(|_| value_overflowed(stringify!($rust_ty), scope)) + }); + } +} +int32_from_value!(Uint32, u8); +int32_from_value!(Uint32, u16); +int32_from_value!(Uint32, u32); +int32_from_value!(Int32, i8); +int32_from_value!(Int32, i16); +int32_from_value!(Int32, i32); + +// `FromValue for f32, f64`. +// +// Note that, as per the rust-reference, +// - "Casting from an f64 to an f32 will produce the closest possible f32" +// https://doc.rust-lang.org/reference/expressions/operator-expr.html#r-expr.as.numeric.float-narrowing +macro_rules! float_from_value { + ($rust_ty:ty) => { + impl_from_value!($rust_ty, (val, scope) => { + cast!(scope, val, Number, "number for `{}`", stringify!($rust_ty)).map(|n| n.value() as _) + }); + } +} +float_from_value!(f32); +float_from_value!(f64); + +// `FromValue for u64, i64`. +macro_rules! int64_from_value { + ($rust_ty:ty, $conv_method: ident) => { + impl_from_value!($rust_ty, (val, scope) => { + let rust_ty = stringify!($rust_ty); + let bigint = cast!(scope, val, BigInt, "bigint for `{}`", rust_ty)?; + let (val, ok) = bigint.$conv_method(); + ok.then_some(val).ok_or_else(|| value_overflowed(rust_ty, scope)) + }); + } +} +int64_from_value!(u64, u64_value); +int64_from_value!(i64, i64_value); + +/// Converts `bigint` into its signnedness and its list of bytes in little-endian, +/// or errors on overflow or unwanted signedness. +/// +/// Parameters: +/// - `N` are the number of bytes to accept at most. +/// - `W = N / 8` are the number of words to accept at most. +/// - `UNSIGNED` is `true` if only unsigned integers are accepted. +/// - `rust_ty` is the target type as a string, for errors. +/// - `scope` for any JS exceptions that need to be raised. +/// - `bigint` is the integer to convert. +fn bigint_to_bytes<'s, const N: usize, const W: usize, const UNSIGNED: bool>( + rust_ty: &str, + scope: &mut HandleScope<'s>, + bigint: &BigInt, +) -> ValueResult<'s, (bool, [u8; N])> +where + [[u8; 8]; W]: NoUninit, + [u8; N]: AnyBitPattern, +{ + // Read the words. + let mut words = [0u64; W]; + let (sign, _) = bigint.to_words_array(&mut words); + + if bigint.word_count() > W { + // There's an under-/over-flow if the caller cannot handle that many words. + return Err(if sign { + value_underflowed(rust_ty, scope) + } else { + value_overflowed(rust_ty, scope) + }); + } + + if sign && UNSIGNED { + // There's an overflow if the caller cannot accept negative numbers. + return Err(value_overflowed(rust_ty, scope)); + } + + // convert the words to little-endian bytes. + let bytes = bytemuck::must_cast(words.map(|w| w.to_le_bytes())); + Ok((sign, bytes)) +} + +// `FromValue for u128, u256`. +macro_rules! unsigned_bigint_from_value { + ($rust_ty:ty, $bytes:literal, $words:literal) => { + impl_from_value!($rust_ty, (val, scope) => { + let rust_ty = stringify!($rust_ty); + let bigint = cast!(scope, val, v8::BigInt, "bigint for `{}`", rust_ty)?; + if let (val, true) = bigint.u64_value() { + // Fast path. + return Ok(val.into()); + } + let (_, bytes) = bigint_to_bytes::<$bytes, $words, true>(rust_ty, scope, &bigint)?; + Ok(Self::from_le_bytes(bytes)) + }); + }; +} +unsigned_bigint_from_value!(u128, 16, 2); +unsigned_bigint_from_value!(u256, 32, 4); + +// `FromValue for i128, i256`. +macro_rules! signed_bigint_from_value { + ($rust_ty:ty, $bytes:literal, $words:literal) => { + impl_from_value!($rust_ty, (val, scope) => { + let rust_ty = stringify!($rust_ty); + let bigint = cast!(scope, val, v8::BigInt, "bigint for `{}`", rust_ty)?; + if let (val, true) = bigint.i64_value() { + // Fast path. + return Ok(val.into()); + } + let (sign, bytes) = bigint_to_bytes::<$bytes, $words, false>(rust_ty, scope, &bigint)?; + let x = Self::from_le_bytes(bytes); + Ok(if sign { + // A negative number, but we have a positive number `x`, so we want `-x`. + // If that's not possible, and as we know there's no underflow, we have `MIN`. + x.checked_neg().unwrap_or(Self::MIN) + } else { + x + }) + }); + }; +} +signed_bigint_from_value!(i128, 16, 2); +signed_bigint_from_value!(i256, 32, 4); diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 2581dc9f708..fd49d1e5a3c 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -12,6 +12,8 @@ use anyhow::anyhow; use spacetimedb_datastore::locking_tx_datastore::MutTxId; use std::sync::{Arc, LazyLock}; +mod error; +mod from_value; mod to_value; /// The V8 runtime, for modules written in e.g., JS or TypeScript. @@ -26,6 +28,13 @@ impl ModuleRuntime for V8Runtime { } } +#[cfg(test)] +impl V8Runtime { + fn init_for_test() { + LazyLock::force(&V8_RUNTIME_GLOBAL); + } +} + static V8_RUNTIME_GLOBAL: LazyLock = LazyLock::new(V8RuntimeInner::init); /// The actual V8 runtime, with initialization of V8. diff --git a/crates/core/src/host/v8/to_value.rs b/crates/core/src/host/v8/to_value.rs index ca65f9f7f8b..4ab82496104 100644 --- a/crates/core/src/host/v8/to_value.rs +++ b/crates/core/src/host/v8/to_value.rs @@ -7,7 +7,7 @@ use v8::{BigInt, Boolean, HandleScope, Integer, Local, Number, Value}; /// Types that can be converted to a v8-stack-allocated [`Value`]. /// The conversion can be done without the possibility for error. pub(super) trait ToValue { - /// Convert `self` within `scope` (a sort of stack management in V8) to a [`Value`]. + /// Converts `self` within `scope` (a sort of stack management in V8) to a [`Value`]. fn to_value<'s>(&self, scope: &mut HandleScope<'s>) -> Local<'s, Value>; } @@ -65,6 +65,8 @@ where impl_to_value!(u128, (val, scope) => le_bytes_to_bigint::<16, 2>(scope, false, val.to_le_bytes())); impl_to_value!(u256, (val, scope) => le_bytes_to_bigint::<32, 4>(scope, false, val.to_le_bytes())); +pub(super) const WORD_MIN: u64 = i64::MIN as u64; + /// Returns `iN::MIN` for `N = 8 * WORDS` as a V8 [`BigInt`]. /// /// Examples: @@ -72,7 +74,6 @@ impl_to_value!(u256, (val, scope) => le_bytes_to_bigint::<32, 4>(scope, false, v /// `i128::MIN` becomes `-1 * (0 * (2^64)^0 + WORD_MIN * (2^64)^1) = -1 * WORD_MIN * 2^64` /// `i256::MIN` becomes `-1 * (0 * (2^64)^0 + 0 * (2^64)^1 + WORD_MIN * (2^64)^2) = -1 * WORD_MIN * (2^128)` fn signed_min_bigint<'s, const WORDS: usize>(scope: &mut HandleScope<'s>) -> Local<'s, BigInt> { - const WORD_MIN: u64 = i64::MIN as u64; let words = &mut [0u64; WORDS]; if let [.., last] = words.as_mut_slice() { *last = WORD_MIN; @@ -100,3 +101,64 @@ impl_to_value!(i256, (val, scope) => { None => signed_min_bigint::<4>(scope), } }); + +#[cfg(test)] +mod test { + use super::super::from_value::FromValue; + use super::super::V8Runtime; + use super::*; + use core::fmt::Debug; + use proptest::prelude::*; + use spacetimedb_sats::proptest::{any_i256, any_u256}; + use v8::{Context, ContextScope, HandleScope, Isolate}; + + /// Roundtrips `rust_val` via `ToValue` to the V8 representation + /// and then back via `FromValue`, + /// asserting that it's the same as the passed value. + fn assert_roundtrips(rust_val: T) { + // Setup V8 and get a `HandleScope`. + V8Runtime::init_for_test(); + let isolate = &mut Isolate::new(<_>::default()); + let scope = &mut HandleScope::new(isolate); + let context = Context::new(scope, Default::default()); + let scope = &mut ContextScope::new(scope, context); + + // Convert to JS and then back. + let js_val = rust_val.to_value(scope); + let rust_val_prime = T::from_value(js_val, scope).unwrap(); + + // We should end up where we started. + assert_eq!(rust_val, rust_val_prime); + } + + proptest! { + #[test] fn test_bool(x: bool) { assert_roundtrips(x); } + + #[test] fn test_f32(x: f32) { assert_roundtrips(x); } + #[test] fn test_f64(x: f64) { assert_roundtrips(x); } + + #[test] fn test_u8(x: u8) { assert_roundtrips(x); } + #[test] fn test_u16(x: u16) { assert_roundtrips(x); } + #[test] fn test_u32(x: u32) { assert_roundtrips(x); } + #[test] fn test_u64(x: u64) { assert_roundtrips(x); } + #[test] fn test_u128(x: u128) { assert_roundtrips(x); } + #[test] fn test_u256(x in any_u256()) { assert_roundtrips(x); } + + #[test] fn test_i8(x: i8) { assert_roundtrips(x); } + #[test] fn test_i16(x: i16) { assert_roundtrips(x); } + #[test] fn test_i32(x: i32) { assert_roundtrips(x); } + #[test] fn test_i64(x: i64) { assert_roundtrips(x); } + #[test] fn test_i128(x: i128) { assert_roundtrips(x); } + #[test] fn test_i256(x in any_i256()) { assert_roundtrips(x); } + } + + #[test] + fn test_signed_mins() { + assert_roundtrips(i8::MIN); + assert_roundtrips(i16::MIN); + assert_roundtrips(i32::MIN); + assert_roundtrips(i64::MIN); + assert_roundtrips(i128::MIN); + assert_roundtrips(i256::MIN); + } +} diff --git a/crates/sats/src/proptest.rs b/crates/sats/src/proptest.rs index bc1405c1001..f0344520714 100644 --- a/crates/sats/src/proptest.rs +++ b/crates/sats/src/proptest.rs @@ -103,11 +103,13 @@ fn generate_non_compound + 'static>() -> B any::().prop_map(Into::into).boxed() } -fn any_u256() -> impl Strategy { +/// Generates any `u256`. +pub fn any_u256() -> impl Strategy { any::<(u128, u128)>().prop_map(|(hi, lo)| u256::from_words(hi, lo)) } -fn any_i256() -> impl Strategy { +/// Generates any `i256`. +pub fn any_i256() -> impl Strategy { any::<(i128, i128)>().prop_map(|(hi, lo)| i256::from_words(hi, lo)) }