diff --git a/packages/runtime/src/scr_dyn_array_like.c b/packages/runtime/src/scr_dyn_array_like.c index 3ac5460849..5d341816d9 100644 --- a/packages/runtime/src/scr_dyn_array_like.c +++ b/packages/runtime/src/scr_dyn_array_like.c @@ -171,8 +171,8 @@ static ScrDyn *al_item(ScrDyn *recv, size_t index) { } /* A primitive receiver's ToObject wrapper is fresh and unobservable: the - * generic array algorithms read through it and discard their writes, which - * is exactly what a wrapper nobody holds observes. */ + * generic array algorithms read through it, and a write the wrapper accepts + * is discarded with it. */ static bool al_primitive(const ScrDyn *recv) { switch (recv->kind) { case SCR_DYN_BOOL: @@ -186,10 +186,49 @@ static bool al_primitive(const ScrDyn *recv) { } } +/* The "not supported yet" Error for a method whose result would be a + * primitive receiver's wrapper object. */ +static void al_refuse_primitive(const ScrStr *method) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "Array.prototype."); + scr_jb_write(&b, method->data, method->len); + scr_jb_puts(&b, " on a primitive receiver is not supported yet"); + scr_throw_error(SCR_ERR_ERROR, scr_jb_finish(&b)); +} + +/* A String wrapper's indexes below its UTF-16 length are read-only and + * non-configurable, so Set throws on them and DeletePropertyOrThrow does + * too; an index at or past the length is an ordinary property of the + * wrapper. Number, boolean, bigint and symbol wrappers carry no index. */ +static bool al_string_index(const ScrDyn *recv, size_t index) { + return recv->kind == SCR_DYN_STR && (double)index < scr_str_utf16_len(recv->v.str); +} + +/* V8's TypeError for a write or delete the String wrapper refuses; `key` + * is the property's name. */ +static void al_string_refuse(const char *key, bool deleting) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, deleting ? "Cannot delete property '" : "Cannot assign to read only property '"); + scr_jb_puts(&b, key); + scr_jb_puts(&b, deleting ? "' of [object String]" : "' of object '[object String]'"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); +} + +static void al_string_refuse_index(size_t index, bool deleting) { + char text[32]; + snprintf(text, sizeof text, "%zu", index); + al_string_refuse(text, deleting); +} + /* Set(O, index, value, true): a failing write throws from scr_dyn_key_set * and the caller checks scr_exc_pending(). */ static void al_set(ScrDyn *recv, size_t index, ScrDyn *value) { - if (al_primitive(recv)) return; + if (al_primitive(recv)) { + if (al_string_index(recv, index)) al_string_refuse_index(index, false); + return; + } ScrStr *key = al_key(index); scr_dyn_key_set(recv, key, value); scr_str_release(key); @@ -197,15 +236,22 @@ static void al_set(ScrDyn *recv, size_t index, ScrDyn *value) { /* DeletePropertyOrThrow(O, index). */ static void al_delete(ScrDyn *recv, size_t index) { - if (al_primitive(recv)) return; + if (al_primitive(recv)) { + if (al_string_index(recv, index)) al_string_refuse_index(index, true); + return; + } ScrStr *key = al_key(index); scr_dyn_key_delete(recv, key, true); scr_str_release(key); } -/* Set(O, "length", length, true). */ +/* Set(O, "length", length, true). A String wrapper's length is read-only + * whatever the value. */ static void al_set_length(ScrDyn *recv, size_t length) { - if (al_primitive(recv)) return; + if (al_primitive(recv)) { + if (recv->kind == SCR_DYN_STR) al_string_refuse("length", false); + return; + } ScrStr *key = scr_str_new("length", 6); ScrDyn *value = scr_dyn_new_num((double)length); scr_dyn_key_set(recv, key, value); @@ -213,20 +259,25 @@ static void al_set_length(ScrDyn *recv, size_t length) { scr_str_release(key); } -/* The receiver's elements in index order as a fresh dyn array. Every index - * the receiver does not carry becomes undefined, as toReversed, toSorted - * and with create their result with CreateDataProperty for every index. - * Owned (+1), NULL with the exception pending. */ -static ScrDyn *al_snapshot(ScrDyn *recv, size_t len) { +/* fill, copyWithin and reverse return O, which for a primitive receiver is + * its ToObject wrapper. A compiled program has no wrapper object to return, + * so for a primitive the method throws the "not supported yet" Error. */ +static ScrDyn *al_receiver_result(ScrDyn *recv, const ScrStr *method) { + if (!al_primitive(recv)) return scr_dyn_retain(recv); + al_refuse_primitive(method); + return NULL; +} + +/* The receiver's elements in index order as a fresh dyn array, each read + * with Get alone (an index the receiver does not carry reads undefined), as + * toSorted and with read through holes; the element at `skip` (SIZE_MAX: + * none) is `replacement` and is never read. Owned (+1), NULL with the + * exception pending. */ +static ScrDyn *al_snapshot(ScrDyn *recv, size_t len, size_t skip, ScrDyn *replacement) { ScrDyn *out = scr_dyn_new_arr(); for (size_t i = 0; i < len; i++) { if (len > 1000000 && !al_iteration(i)) { scr_dyn_release(out); return NULL; } - if (!al_present(recv, i)) { - if (scr_exc_pending()) { scr_dyn_release(out); return NULL; } - scr_dyn_arr_push(out, scr_dyn_retain(scr_dyn_undefined())); - continue; - } - ScrDyn *item = al_item(recv, i); + ScrDyn *item = i == skip ? scr_dyn_retain(replacement) : al_item(recv, i); if (!item) { scr_dyn_release(out); return NULL; } scr_dyn_arr_push(out, item); } @@ -272,12 +323,32 @@ static bool al_cb_check(ScrDyn *const *args, size_t argc) { return false; } +/* The methods this unit runs; every other name is the invoke unit's. */ +static bool al_owns(const ScrStr *method) { + static const char *const names[] = { + "concat", "copyWithin", "fill", "flat", "flatMap", "join", "pop", "push", "reverse", + "shift", "splice", "toReversed", "toSorted", "toSpliced", "unshift", "with", + }; + for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) + if (al_is(method, names[i])) return true; + return false; +} + /* One packed argument list, borrowed: the array dispatch hands the * algorithm its arguments as a dyn array. */ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { if (recv->kind == SCR_DYN_ARR || - (recv->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(recv))) + (recv->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(recv))) { + /* An array receiver runs this unit's methods through the array + * dispatch, which implements each of them for arrays. */ + char name[16]; + if (al_owns(method) && args && args->kind == SCR_DYN_ARR) { + memcpy(name, method->data, method->len); + name[method->len] = '\0'; + return scr_dyn_invoke(recv, name, args->v.arr.items, args->v.arr.len, name); + } return scr_dyn_array_proto_call(recv, method, args); + } /* Step 1 of each of these algorithms: `? ToObject(this value)`. The * array dispatch answers it for its own receivers; a nullish one never * reaches a length read. */ @@ -289,6 +360,19 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { if (!args || args->kind != SCR_DYN_ARR) scr_trap("scriptc: invalid Array.prototype arguments\n"); ScrDyn *const *argv = args->v.arr.items; size_t argc = args->v.arr.len; + if (al_is(method, "concat")) { + /* A receiver that is not an array is not spreadable: it is the result's + * first element, and its length is never read. The arguments then + * spread as they do for an array receiver, so the array dispatch + * answers `[receiver].concat(...args)`. A primitive receiver's first + * element would be its wrapper object. */ + if (al_primitive(recv)) { al_refuse_primitive(method); return NULL; } + ScrDyn *head = scr_dyn_new_arr(); + scr_dyn_arr_push(head, scr_dyn_retain(recv)); + ScrDyn *out = scr_dyn_invoke(head, "concat", argv, argc, "concat"); + scr_dyn_release(head); + return out; + } size_t len; if (!al_length(recv, &len)) return NULL; @@ -394,7 +478,7 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { scr_dyn_release(upper_value); if (scr_exc_pending()) return NULL; } - return scr_dyn_retain(recv); + return al_receiver_result(recv, method); } if (al_is(method, "join")) { ScrStr *sep = argc > 0 && argv[0]->kind != SCR_DYN_UNDEF @@ -441,7 +525,7 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { al_set(recv, i, value); if (scr_exc_pending()) return NULL; } - return scr_dyn_retain(recv); + return al_receiver_result(recv, method); } if (al_is(method, "copyWithin")) { double target_d = 0; @@ -453,7 +537,7 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { size_t to = al_rel_index(trunc(target_d), len); size_t from = al_rel_index(trunc(start_d), len); size_t end = al_rel_index(trunc(end_d), len); - if (from >= end || to >= len) return scr_dyn_retain(recv); + if (from >= end || to >= len) return al_receiver_result(recv, method); size_t count = end - from < len - to ? end - from : len - to; bool backwards = from < to && to < from + count; for (size_t step = 0; step < count; step++) { @@ -470,31 +554,7 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { } if (scr_exc_pending()) return NULL; } - return scr_dyn_retain(recv); - } - if (al_is(method, "concat")) { - /* The receiver's own elements spread one level when it is a native - * array; an array-like receiver is appended as a single value, as - * Array.prototype.concat treats a non-array `this`. */ - ScrDyn *out = scr_dyn_new_arr(); - if (recv->kind == SCR_DYN_ARR) { - for (size_t i = 0; i < len; i++) - if (!al_copy_item(out, recv, i)) { scr_dyn_release(out); return NULL; } - } else { - scr_dyn_arr_push(out, scr_dyn_retain(recv)); - } - /* An array argument spreads one level (holes included); every other - * value is appended as itself. */ - for (size_t a = 0; a < argc; a++) { - ScrDyn *arg = argv[a]; - if (arg->kind != SCR_DYN_ARR) { - scr_dyn_arr_push(out, scr_dyn_retain(arg)); - continue; - } - for (size_t i = 0; i < arg->v.arr.len; i++) - if (!al_copy_item(out, arg, i)) { scr_dyn_release(out); return NULL; } - } - return out; + return al_receiver_result(recv, method); } if (al_is(method, "splice")) { /* No arguments is the one form whose actualStart is len, not 0: it @@ -576,18 +636,19 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { scr_throw_error_msg(SCR_ERR_RANGE, msg, sizeof msg - 1); return NULL; } - ScrDyn *out = al_snapshot(recv, len); - if (!out) return NULL; - al_set(out, (size_t)index, argc > 1 ? argv[1] : scr_dyn_undefined()); - if (scr_exc_pending()) { scr_dyn_release(out); return NULL; } - return out; + return al_snapshot(recv, len, (size_t)index, argc > 1 ? argv[1] : scr_dyn_undefined()); } if (al_is(method, "toReversed")) { - ScrDyn *out = al_snapshot(recv, len); - if (!out) return NULL; - ScrDyn *reversed = scr_dyn_invoke(out, "reverse", NULL, 0, "toReversed"); - scr_dyn_release(out); - return reversed; + /* Get(O, len - k - 1) for k ascending: the reads go from the last + * index down. */ + ScrDyn *out = scr_dyn_new_arr(); + for (size_t k = 0; k < len; k++) { + if (len > 1000000 && !al_iteration(k)) { scr_dyn_release(out); return NULL; } + ScrDyn *item = al_item(recv, len - k - 1); + if (!item) { scr_dyn_release(out); return NULL; } + scr_dyn_arr_push(out, item); + } + return out; } if (al_is(method, "toSorted")) { ScrDyn *cmp = argc > 0 ? argv[0] : scr_dyn_undefined(); @@ -596,7 +657,7 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); return NULL; } - ScrDyn *out = al_snapshot(recv, len); + ScrDyn *out = al_snapshot(recv, len, SIZE_MAX, NULL); if (!out) return NULL; if (out->v.arr.len < 2) return out; ScrDyn *sorted = scr_dyn_invoke(out, "sort", argv, argc, "toSorted"); @@ -607,7 +668,8 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { double start_d = 0; if (argc > 0 && !al_number(argv[0], &start_d)) return NULL; size_t start = al_rel_index(trunc(start_d), len); - size_t removed = len - start; + /* Without `start` nothing is skipped: the result is a copy. */ + size_t removed = argc > 0 ? len - start : 0; if (argc > 1) { double count_d = 0; if (!al_number(argv[1], &count_d)) return NULL; @@ -629,15 +691,21 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { scr_throw_error_msg(SCR_ERR_RANGE, msg, sizeof msg - 1); return NULL; } + /* Every copied index is read with Get and defined on the result, so a + * missing one becomes an own undefined, not a hole. */ ScrDyn *out = scr_dyn_new_arr(); for (size_t i = 0; i < start; i++) { if (len > 1000000 && !al_iteration(i)) { scr_dyn_release(out); return NULL; } - if (!al_copy_item(out, recv, i)) { scr_dyn_release(out); return NULL; } + ScrDyn *item = al_item(recv, i); + if (!item) { scr_dyn_release(out); return NULL; } + scr_dyn_arr_push(out, item); } for (size_t j = 2; j < argc; j++) scr_dyn_arr_push(out, scr_dyn_retain(argv[j])); for (size_t i = start + removed; i < len; i++) { if (len > 1000000 && !al_iteration(i)) { scr_dyn_release(out); return NULL; } - if (!al_copy_item(out, recv, i)) { scr_dyn_release(out); return NULL; } + ScrDyn *item = al_item(recv, i); + if (!item) { scr_dyn_release(out); return NULL; } + scr_dyn_arr_push(out, item); } return out; } @@ -661,7 +729,9 @@ ScrDyn *scr_dyn_array_like_call(ScrDyn *recv, ScrStr *method, ScrDyn *args) { if (mapping) { ScrDyn *index = scr_dyn_new_num((double)i); ScrDyn *cbargs[3] = { item, index, recv }; + scr_dyn_this_push_dyn(argc > 1 ? argv[1] : scr_dyn_undefined()); ScrDyn *result = scr_dyn_call(argv[0], cbargs, 3, "callback"); + scr_dyn_this_pop(); scr_dyn_release(index); scr_dyn_release(item); if (!result) { scr_dyn_release(mapped); return NULL; } diff --git a/tests/corpus/js-array-proto-array-likes.js b/tests/corpus/js-array-proto-array-likes.js index 41fe61eb70..b31be0df86 100644 --- a/tests/corpus/js-array-proto-array-likes.js +++ b/tests/corpus/js-array-proto-array-likes.js @@ -40,7 +40,7 @@ report('flat', Array.prototype.flat.call(nested, 1).join('')); report('flat-deep', Array.prototype.flat.call(nested, 2).join('')); report('flatMap', Array.prototype.flatMap.call(nested, (value) => [value, value]).length); -// Holes and absent indexes stay holes. +// toReversed defines every index on its result, so a hole becomes undefined. const sparse = { length: 3, 0: 'a', 2: 'c' }; const copied = Array.prototype.toReversed.call(sparse); report('holes', '1' in copied ? 'present' : 'absent'); @@ -69,4 +69,84 @@ attempt('null-toReversed', () => Array.prototype.toReversed.call(null)); attempt('undefined-with', () => Array.prototype.with.call(undefined, 0, 1)); attempt('null-splice', () => Array.prototype.splice.call(null, 0, 1)); +// A proxy over `target` that logs its string-keyed reads and its has checks. +const traced = (target) => { + const log = []; + const proxy = new Proxy(target, { + get(object, key, receiver) { + if (typeof key === 'string') log.push('get:' + key); + return Reflect.get(object, key, receiver); + }, + has(object, key) { + log.push('has:' + String(key)); + return Reflect.has(object, key); + }, + }); + return { proxy, log }; +}; +// concat appends a receiver that is not spreadable as one value, without +// reading its length. +const unread = traced({ length: 2, 0: 'a', 1: 'b' }); +const joined = Array.prototype.concat.call(unread.proxy, [1]); +report('concat-length', joined.length); +report('concat-reads', unread.log.join(',')); + +// flatMap calls the callback with thisArg as `this`. +const tagged = { length: 2, 0: 'a', 1: 'b' }; +report('flatMap-this', Array.prototype.flatMap.call(tagged, function (value) { + return [value, this.tag]; +}, { tag: 't' }).join('')); + +// A string's ToObject wrapper has read-only indexes below its length and a +// read-only length, so the mutating methods throw on them. +attempt('string-push', () => Array.prototype.push.call('ab', 'c')); +attempt('string-pop', () => Array.prototype.pop.call('ab')); +attempt('string-pop-empty', () => Array.prototype.pop.call('')); +attempt('string-shift', () => Array.prototype.shift.call('ab')); +attempt('string-unshift', () => Array.prototype.unshift.call('ab', 'x')); +attempt('string-unshift-none', () => Array.prototype.unshift.call('ab')); +attempt('string-reverse', () => Array.prototype.reverse.call('ab')); +attempt('string-fill', () => Array.prototype.fill.call('ab', 'x')); +attempt('string-copyWithin', () => Array.prototype.copyWithin.call('abc', 0, 1)); +attempt('string-splice', () => Array.prototype.splice.call('abc', 1, 1)); +attempt('string-splice-insert', () => Array.prototype.splice.call('', 0, 0, 'x')); +attempt('string-splice-none', () => Array.prototype.splice.call('abc')); +// Other primitives' wrappers carry no indexes and a writable length. +attempt('number-push', () => Array.prototype.push.call(5, 'x')); +attempt('boolean-pop', () => Array.prototype.pop.call(true)); + +// toReversed reads with Get alone, from the last index down. +const backwards = traced({ length: 3, 0: 'a', 2: 'c' }); +report('toReversed', JSON.stringify(Array.prototype.toReversed.call(backwards.proxy))); +report('toReversed-reads', backwards.log.join(',')); + +// with never reads the replaced index, and reads every other one with Get. +const replaced = traced({ length: 3, 0: 'a', 2: 'c' }); +report('with', JSON.stringify(Array.prototype.with.call(replaced.proxy, 0, 'z'))); +report('with-reads', replaced.log.join(',')); + +// toSorted reads every index with Get, holes included. +const sorted = traced({ length: 3, 0: 'b', 2: 'a' }); +report('toSorted', JSON.stringify(Array.prototype.toSorted.call(sorted.proxy))); +report('toSorted-reads', sorted.log.join(',')); + +// toSpliced without arguments copies the receiver; a missing index becomes +// an own undefined, read with Get alone. +const copiedOut = traced({ length: 3, 0: 'a', 2: 'c' }); +const copy = Array.prototype.toSpliced.call(copiedOut.proxy); +report('toSpliced-none', JSON.stringify(copy) + ' ' + ('1' in copy)); +report('toSpliced-none-reads', copiedOut.log.join(',')); +const skipped = traced({ length: 4, 0: 'a', 3: 'd' }); +report('toSpliced-holes', JSON.stringify(Array.prototype.toSpliced.call(skipped.proxy, 1, 1, 'X'))); +report('toSpliced-reads', skipped.log.join(',')); + +// An array receiver held as an untyped value runs the array methods. +const either = (useArray) => (useArray ? ['p', 'q'] : { length: 0 }); +const list = either(resolved.length > 0); +report('array-push', Array.prototype.push.call(list, 'r')); +report('array-reverse', Array.prototype.join.call(Array.prototype.reverse.call(list), '')); +report('array-concat', Array.prototype.concat.call(list, ['s'], 't').join('')); +report('array-toSpliced', Array.prototype.toSpliced.call(list, 0, 1).join('')); +report('array-with', Array.prototype.with.call(list, 0, 'z').join('')); + console.log(resolved.join('\n')); diff --git a/tests/harness/array-like-primitive-refusals.test.ts b/tests/harness/array-like-primitive-refusals.test.ts new file mode 100644 index 0000000000..c4523e30a7 --- /dev/null +++ b/tests/harness/array-like-primitive-refusals.test.ts @@ -0,0 +1,57 @@ +import { execFile } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); + +// fill, copyWithin and reverse return their receiver, and concat's result +// starts with it: for a primitive receiver that is its wrapper object, which +// a compiled program has no value for, so these calls refuse with a catchable +// error. A write the String wrapper refuses still throws its TypeError first. +test("Array.prototype methods whose result is a primitive's wrapper refuse", async () => { + const outDir = mkdtempSync(join(tmpdir(), "scriptc-array-like-refusals-")); + const entry = join(outDir, "main.js"); + writeFileSync( + entry, + ` +function attempt(run) { + try { run(); console.log("returned"); } catch (error) { console.log(error.name, error.message); } +} +attempt(() => Array.prototype.reverse.call("a")); +attempt(() => Array.prototype.fill.call(5, 0)); +attempt(() => Array.prototype.fill.call("ab", "x", 2)); +attempt(() => Array.prototype.copyWithin.call(true, 0, 0)); +attempt(() => Array.prototype.concat.call("ab", [1])); +attempt(() => Array.prototype.reverse.call("ab")); +attempt(() => Array.prototype.join.call("ab", "-")); +`, + ); + const result = await compile(entry, { + backend: "llvm", + sanitize: process.env["SCRIPTC_SAN"] === "1", + outDir, + outPath: join(outDir, "program"), + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + if (!result.ok) return; + const { stdout, stderr } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" }); + const refused = (method: string): string => + `Error Array.prototype.${method} on a primitive receiver is not supported yet`; + expect(stdout).toBe( + [ + refused("reverse"), + refused("fill"), + refused("fill"), + refused("copyWithin"), + refused("concat"), + "TypeError Cannot assign to read only property '0' of object '[object String]'", + "returned", + "", + ].join("\n"), + ); + expect(stderr).toBe(""); +}); diff --git a/tests/harness/corpus-costs/js-array-proto-array-likes.js.json b/tests/harness/corpus-costs/js-array-proto-array-likes.js.json index d5fcc27e98..6d743409f8 100644 --- a/tests/harness/corpus-costs/js-array-proto-array-likes.js.json +++ b/tests/harness/corpus-costs/js-array-proto-array-likes.js.json @@ -1 +1 @@ -{ "plain": 3300, "san": 5400 } +{ "plain": 3700, "san": 6200 }