Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions crates/core/proptest-regressions/host/v8/to_value.txt
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions crates/core/src/host/v8/error.rs
Original file line number Diff line number Diff line change
@@ -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<T, Local<'s, Value>>;

/// 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<M>(pub M);

impl<M: IntoJsString> IntoException for TypeError<M> {
fn into_exception<'s>(self, scope: &mut HandleScope<'s>) -> Local<'s, Value> {
let msg = self.0.into_string(scope);
Exception::type_error(scope, msg)
}
}
185 changes: 185 additions & 0 deletions crates/core/src/host/v8/from_value.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#![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<Local<'b, Value>>,
{
val.try_cast::<T>()
.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`.
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 _)
Comment thread
gefjon marked this conversation as resolved.
});
}
}
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);
9 changes: 9 additions & 0 deletions crates/core/src/host/v8/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<V8RuntimeInner> = LazyLock::new(V8RuntimeInner::init);

/// The actual V8 runtime, with initialization of V8.
Expand Down
66 changes: 64 additions & 2 deletions crates/core/src/host/v8/to_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}

Expand Down Expand Up @@ -65,14 +65,15 @@ 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:
/// `i64::MIN` becomes `-1 * WORD_MIN * (2^64)^0 = -1 * WORD_MIN`
/// `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;
Expand Down Expand Up @@ -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<T: ToValue + FromValue + PartialEq + Debug>(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);
}
}
6 changes: 4 additions & 2 deletions crates/sats/src/proptest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,13 @@ fn generate_non_compound<Val: Arbitrary + Into<AlgebraicValue> + 'static>() -> B
any::<Val>().prop_map(Into::into).boxed()
}

fn any_u256() -> impl Strategy<Value = u256> {
/// Generates any `u256`.
pub fn any_u256() -> impl Strategy<Value = u256> {
any::<(u128, u128)>().prop_map(|(hi, lo)| u256::from_words(hi, lo))
}

fn any_i256() -> impl Strategy<Value = i256> {
/// Generates any `i256`.
pub fn any_i256() -> impl Strategy<Value = i256> {
any::<(i128, i128)>().prop_map(|(hi, lo)| i256::from_words(hi, lo))
}

Expand Down
Loading