diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs index 15ebbe734..4ef9b8210 100644 --- a/crates/js/src/builtins/extensions.rs +++ b/crates/js/src/builtins/extensions.rs @@ -9,14 +9,18 @@ //! a script's own method: a getter, a function, a date, a regular expression, an error, a boxed //! primitive, an array with properties besides its elements, an object whose prototype is not //! `Object.prototype`, a property that is not enumerable or any other object that is not plain -//! data throws a `TypeError`, and so does an object that contains itself. A value nested deeper, with more entries or longer text than the +//! data throws a `TypeError`, and so does an object that contains itself. A literal makes an +//! extensible object whose properties are writable and configurable and an array whose `length` +//! is writable, so a property that is not writable or not configurable, an object or array that is +//! not extensible (after `Object.preventExtensions`, `Object.seal` or `Object.freeze`) and an +//! array with a read-only `length` throw a `TypeError` too. A value nested deeper, with more entries or longer text than the //! realm's [`crate::DataLimits`] allow throws a `RangeError`. Every value written takes fuel. use std::cell::{Cell, RefCell}; use std::rc::Rc; use super::{Builder, arg, method}; -use crate::heap::{Class, NativeFn, ObjId, Prop, key}; +use crate::heap::{CONFIGURABLE, Class, NativeFn, ObjId, Object, Prop, WRITABLE, key}; use crate::interp::{ErrorType, Interp, JsResult}; use crate::value::{Value, array_index, index_key, number_to_str}; use crate::{ScriptItem, bytecode::Code}; @@ -289,10 +293,14 @@ impl Source { Class::Ordinary => return Err(interp.type_error(NOT_PLAIN_OBJECT)), _ => None, }; + let fixed = shape.as_ref().and(fixed_state(o)); let Some(shape) = shape else { let what = format!("toSource: a {} cannot be written as source", interp.object(id)?.class.name()); return Err(interp.type_error(&what)); }; + if let Some(what) = fixed { + return Err(interp.type_error(what)); + } self.ancestors.push(id); let r = match shape { Err(length) => self.array(interp, id, length), @@ -347,6 +355,23 @@ const NOT_PLAIN_ARRAY: &str = "toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source"; const NOT_PLAIN_OBJECT: &str = "toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source"; +const NOT_EXTENSIBLE: &str = "toSource: a non-extensible object cannot be written as source"; +const READ_ONLY_LENGTH: &str = "toSource: an array with a read-only length cannot be written as source"; +const FIXED_PROPERTY: &str = "toSource: a property that is not writable or configurable cannot be written as source"; + +/// Why the literal for the array or plain object `o` would not make it again: `o` is not +/// extensible, its `length` is read-only, or one of its data properties is not writable or not +/// configurable. A literal makes each of these open. +fn fixed_state(o: &Object) -> Option<&'static str> { + if !o.extensible { + return Some(NOT_EXTENSIBLE); + } + if matches!(o.class, Class::Array { length_writable: false, .. }) { + return Some(READ_ONLY_LENGTH); + } + let open = WRITABLE | CONFIGURABLE; + o.props.iter().any(|(_, p)| matches!(p, Prop::Data { flags, .. } if flags & open != open)).then_some(FIXED_PROPERTY) +} /// The getter behind `item`; [`ScriptItem::Global`] is a value and has none. pub(crate) fn getter(item: ScriptItem) -> Option<(&'static str, NativeFn)> { diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs index 19a20ab5b..8336643e4 100644 --- a/crates/js/src/extension_tests.rs +++ b/crates/js/src/extension_tests.rs @@ -126,6 +126,45 @@ fn to_source_refuses_what_is_not_plain_data() { assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run"); } +#[test] +fn to_source_refuses_flags_and_extensibility_a_literal_does_not_make() { + let mut r = realm(); + let property = "TypeError: toSource: a property that is not writable or configurable cannot be written as source"; + let extensible = "TypeError: toSource: a non-extensible object cannot be written as source"; + let length = "TypeError: toSource: an array with a read-only length cannot be written as source"; + for (source, want) in [ + ("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true, configurable: true}); toSource(o)", property), + ("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true, writable: true}); toSource(o)", property), + ("var a = [1, 2]; Object.defineProperty(a, '1', {writable: false}); toSource(a)", property), + ("var a = [1, 2]; Object.defineProperty(a, '0', {configurable: false}); a.toSource()", property), + ("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true}); toSource({x: [{b: o}]})", property), + ("toSource(Object.preventExtensions({}))", extensible), + ("toSource(Object.preventExtensions([]))", extensible), + ("toSource(Object.seal({a: 1}))", extensible), + ("toSource(Object.freeze({a: 1}))", extensible), + ("toSource(Object.seal([1]))", extensible), + ("toSource(Object.freeze([1]))", extensible), + ("toSource([1, {b: Object.freeze({})}])", extensible), + ("var a = []; Object.defineProperty(a, 'length', {writable: false}); toSource(a)", length), + ("var a = [1, , 3]; Object.defineProperty(a, 'length', {writable: false}); toSource({a: a})", length), + ] { + assert_eq!(thrown(&mut r, source), want, "{source}"); + } + // The refused states are ones the literal would lose: evaluated, it is open again. + let lost = "var o = Object.freeze({a: 1}); var w = eval('({\"a\":1})'); + [Object.isExtensible(o), Object.isExtensible(w), Object.getOwnPropertyDescriptor(w, 'a').writable].join()"; + assert_eq!(text(&mut r, lost), "false,true,true"); +} + +#[test] +fn to_source_writes_properties_defined_with_every_flag_on() { + let mut r = realm(); + let source = "var o = {}; Object.defineProperty(o, 'a', {value: 1, writable: true, enumerable: true, configurable: true}); + var a = [0]; Object.defineProperty(a, '0', {value: 2, writable: true, enumerable: true, configurable: true}); + toSource({o: o, a: a, e: {}, f: [], s: [1, , 3]})"; + assert_eq!(text(&mut r, source), r#"({"o":({"a":1}), "a":[2], "e":({}), "f":[], "s":[1, , 3]})"#); +} + #[test] fn to_source_of_a_string_with_many_escapes_throws_a_range_error_before_the_heap_limit() { let mut r = realm(); diff --git a/docs/mcp.md b/docs/mcp.md index 7468316bc..291c41230 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1765,7 +1765,9 @@ as it is. A string as the first argument is used as the text. objects as the text of an expression that makes the same value, such as `({"a":[1, "x"]})`. A function, a getter, a date, a regular expression, an error, a host object such as `vc`, an array with properties besides its elements, an object whose prototype is not `Object.prototype`, a property that is not enumerable, or an object that contains -itself throws a `TypeError`. A value deeper than 64 levels, with more than 1,048,576 entries or with text longer than 16,777,216 +itself throws a `TypeError`. So does a property that is not writable or not configurable, an object or array after +`Object.preventExtensions`, `Object.seal` or `Object.freeze`, and an array whose `length` is read-only, since the text +would make each of them writable, configurable and extensible again (VectorCraft's own rule). A value deeper than 64 levels, with more than 1,048,576 entries or with text longer than 16,777,216 characters throws a `RangeError`. The run is one transaction. When the script ends, each document it changed gets one undo step named Run Script. A diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2c2eaa3c4..233614b9f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -740,6 +740,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`. + - Scripts: `toSource` throws a `TypeError` for a property that is not writable or not configurable, a sealed, frozen or non-extensible object or array, and an array with a read-only `length` (VectorCraft's own rule). `crates/js/src/extension_tests.rs`. - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: an uncaught error that one or more `finally` blocks pass on unchanged, in the script or in a function it called, is reported at the line and column where it was thrown, in `script.run`'s report and the Script Console. An error a `finally` block throws is reported at that throw, and one its `return`, `break` or `continue` discards is not reported. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`.