From 447a3cc65febe60c58a3e8f5b6cc84a9a3f23f0f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:46:01 -0800 Subject: [PATCH] JSON: stringify unwraps Number and String objects to their primitive values Number objects made by ToObject now carry their primitive value, as String and Boolean objects already did (ECMA-262 5.1 sections 8.6.2 and 9.9). JSON.stringify reads it for a value, a replacer list entry and the space argument (section 15.12.3, Str step 4, step 4.b.ii and step 5) when the method that ToNumber or ToString calls first is not found before the object's own prototype, so JSON.stringify(Object(3)) gives 3 and JSON.stringify(Object('abc')) gives "abc". A valueOf or toString on the object or on its prototype still runs. --- crates/js/src/builtins.rs | 2 +- crates/js/src/builtins/json.rs | 66 ++++++++++++++++++++++++--------- crates/js/src/builtins_tests.rs | 19 ++++++++++ crates/js/src/heap.rs | 6 +-- crates/js/src/interp.rs | 2 +- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 30bc3be1b..c72382e27 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -78,7 +78,7 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { } interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto)); interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto)); - interp.intrinsics.number_proto = alloc(interp, Class::Number, Some(object_proto)); + interp.intrinsics.number_proto = alloc(interp, Class::Number(0.0), Some(object_proto)); interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean(false), Some(object_proto)); interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto)); let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?; diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 93d9061dc..4aa5c9ac5 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer}; use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes}; -use crate::interp::{Desc, ErrorType, Interp, JsResult}; +use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult}; use crate::value::{Str, Value, number_to_str}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { @@ -459,14 +459,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult match interp.heap.object(*o).map(|x| &x.class) { - Some(Class::Number) => Value::Number(interp.to_number(space)?), - Some(Class::String(_)) => Value::String(interp.to_str(space)?), - _ => space.clone(), - }, - _ => space.clone(), - }; + let space = unwrap_primitive(interp, space, false)?.unwrap_or_else(|| space.clone()); match space { Value::Number(_) => { let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; @@ -489,9 +482,10 @@ fn property_list(interp: &mut Interp, r: ObjId) -> JsResult> { let item = match &v { Value::String(s) => Some(s.clone()), Value::Number(_) => Some(interp.primitive_to_str(&v)), - Value::Object(o) if interp.heap.object(*o).is_some_and(|x| matches!(x.class, Class::String(_) | Class::Number)) => { - Some(interp.to_str(&v)?) - } + Value::Object(_) => match unwrap_primitive(interp, &v, true)? { + Some(Value::String(s)) => Some(s), + _ => None, + }, _ => None, }; let Some(s) = item else { continue }; @@ -546,12 +540,11 @@ fn resolve_rooted(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &K keep(interp, &v); } if let Value::Object(o) = &v { - v = match interp.heap.object(*o).map(|x| &x.class) { - Some(Class::Number) => Value::Number(interp.to_number(&v)?), - Some(Class::String(_)) => Value::String(interp.to_str(&v)?), - Some(Class::Boolean(b)) => Value::Bool(*b), - _ => v, - }; + if let Some(Class::Boolean(b)) = interp.heap.object(*o).map(|x| &x.class) { + v = Value::Bool(*b); + } else if let Some(p) = unwrap_primitive(interp, &v, false)? { + v = p; + } } Ok(match v { Value::Undefined => Item::Skip, @@ -561,6 +554,43 @@ fn resolve_rooted(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &K }) } +/// ToNumber of a Number object, or ToString of a String object (of either when `as_string` is +/// set), as section 15.12.3 applies them in Str step 4, step 4.b.ii and step 5. Returns `None` +/// for any other value. When the method that [[DefaultValue]] (section 8.12.8) calls first is not +/// found on the object or on its prototype chain before the object's standard prototype, the +/// result is the object's [[PrimitiveValue]] (sections 8.6.2 and 9.9), the value that +/// `Number.prototype.valueOf` (15.7.4.4), `Number.prototype.toString` (15.7.4.2) and +/// `String.prototype.toString` (15.5.4.2) return. Otherwise ToPrimitive calls the method found. +fn unwrap_primitive(interp: &mut Interp, v: &Value, as_string: bool) -> JsResult> { + let Value::Object(o) = v else { return Ok(None) }; + let (primitive, proto, string) = match interp.heap.object(*o).map(|x| &x.class) { + Some(Class::Number(n)) => (Value::Number(*n), interp.intrinsics.number_proto, as_string), + Some(Class::String(s)) => (Value::String(s.clone()), interp.intrinsics.string_proto, true), + _ => return Ok(None), + }; + let (hint, name) = if string { (Hint::String, "toString") } else { (Hint::Number, "valueOf") }; + let p = if reaches_prototype(interp, *o, proto, name)? { primitive } else { interp.to_primitive(v, hint)? }; + Ok(Some(if string { Value::String(interp.primitive_to_str(&p)) } else { Value::Number(interp.to_number(&p)?) })) +} + +/// Whether looking up `name` from `o` reaches the object `proto` on the prototype chain before +/// any object on the way, `proto` included, has its own property `name`. +fn reaches_prototype(interp: &mut Interp, o: ObjId, proto: ObjId, name: &str) -> JsResult { + let k = key(name); + let mut cur = Some(o); + while let Some(id) = cur { + interp.use_fuel()?; + if interp.get_own_property(id, &k)?.is_some() { + return Ok(false); + } + if id == proto { + return Ok(true); + } + cur = interp.heap.object(id).and_then(|x| x.proto); + } + Ok(false) +} + /// Quote (section 15.12.3): the string in double quotes with the characters JSON escapes. fn write_quoted(interp: &mut Interp, out: &mut Builder, s: &[u16]) -> JsResult<()> { out.push_units(interp, &[QUOTE])?; diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index aaf2d245a..a9184c0c3 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -529,6 +529,25 @@ fn json_stringify_writes_values_with_replacers_and_indentation() { assert_eq!(string("var x = {}; JSON.stringify([x, x])"), "[{},{}]"); } +#[test] +fn json_stringify_unwraps_number_string_and_boolean_objects() { + assert_eq!(string("JSON.stringify(Object(3))"), "3"); + assert_eq!(string("JSON.stringify(Object('abc'))"), "\"abc\""); + assert_eq!(string("JSON.stringify(Object(true))"), "true"); + assert_eq!(string("JSON.stringify({n: Object(-0), m: Object(NaN), s: Object('')})"), "{\"n\":0,\"m\":null,\"s\":\"\"}"); + assert_eq!(string("JSON.stringify([Object.getPrototypeOf(Object(3)), Object.getPrototypeOf(Object('abc'))])"), "[0,\"\"]"); + // The space argument (step 5). + assert_eq!(string("JSON.stringify([1], null, Object(2))"), "[\n 1\n]"); + assert_eq!(string("JSON.stringify([1], null, Object('--'))"), "[\n--1\n]"); + // Replacer list entries (step 4.b.ii). + assert_eq!(string("JSON.stringify({a: 1, 3: 2, b: 3}, [Object('a'), Object(3)])"), "{\"a\":1,\"3\":2}"); + // Methods on Object.prototype do not reach the objects; methods on their own prototypes do. + assert_eq!(string("Object.prototype.toString = function () { return 'z'; }; JSON.stringify(Object('abc'))"), "\"abc\""); + assert_eq!(string("Object.prototype.valueOf = function () { return 9; }; JSON.stringify(Object(3))"), "3"); + assert_eq!(string("Object.getPrototypeOf(Object(1)).valueOf = function () { return 4; }; JSON.stringify(Object(3))"), "4"); + assert_eq!(string("Object.getPrototypeOf(Object('')).toString = function () { return 'q'; }; JSON.stringify(Object('abc'))"), "\"q\""); +} + #[test] fn deep_json_nesting_needs_no_native_stack() { let handle = std::thread::Builder::new() diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 66dc00b24..143de10c3 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -209,8 +209,8 @@ pub(crate) enum Class { Arguments(Box), /// A `Boolean` object made by ToObject, with its primitive value. Boolean(bool), - /// A `Number` object made by ToObject. - Number, + /// A `Number` object made by ToObject, with its primitive value. + Number(f64), String(Str), RegExp, ForIn(Box), @@ -232,7 +232,7 @@ impl Class { Class::Error => "Error", Class::Arguments(_) => "Arguments", Class::Boolean(_) => "Boolean", - Class::Number => "Number", + Class::Number(_) => "Number", Class::String(_) => "String", Class::RegExp => "RegExp", } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 2293984ce..472d21c19 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2240,7 +2240,7 @@ impl Interp { Value::Object(o) => return Ok(*o), Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto), - Value::Number(_) => (Class::Number, self.intrinsics.number_proto), + Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; Ok(self.heap.alloc_object(Object::new(class, Some(proto))))