[core] update all of core to use safe iteration

Brings the safe-iteration work over from the now-outdated
feat/safe-iteration (9d627e36, c0332df0) and applies it to everything that
has landed since. That branch's other commit, 91264d0a, is already on
develop through the stack.

`for...of`, spread and array destructuring all run the iteration protocol,
whose lookups land on `Array.prototype[@@iterator]` and
`%IteratorPrototype%` - both writable by the page. Any of them in the page
realm lets a page change what scramjet believes it read, or what it
passes on after checking: `el.prepend(...args)` spread `args` into the
native after `text.around` had inspected them, so a replaced array
iterator could hand the native a different node.

- `drain(xs)` in the snapshot: a for...of-able view of an array built
  from own properties, so none of the protocol's lookups reach the page.
- `no-unsafe-iteration` lint rule, at "error" for src/client and
  src/Tap.ts, with tests. Its messages now point at `drain` and
  `Reflect_apply`.
- All 45 sites it found: spreads into natives become
  `Reflect_apply(super.m, this, args)` (node.ts, reflect.ts, css.ts,
  settimeout, importScripts); loops become `drain`; the NamedNodeMap
  fallback indexes the native rather than running its iterator; Headers
  are copied with the native's own `forEach`. The brand-check plugin
  learns that `Reflect_apply(super.m, this, ...)` calls the native.
  `observedAttributes` keeps `for...of`: it is the page's own iterable,
  and the browser runs its protocol too.
- Things the rule cannot see: `Promise_all` still walked its argument
  with the protocol and called each element's `then`; it is now a
  snapshot helper over `Promise_then`, and the unused `Promise_race`,
  `Promise_allSettled` and `Promise_any` are gone. Tap.dispatch, which
  runs in the page on every navigation, drops spread, `.map`, `.find`,
  `.includes` and `.push`. `Array.prototype` method calls in
  client.ts, chrome.ts, event.ts, err.ts and webidl.ts go through the
  snapshot, and `client.native`'s getters and setters, and
  sourcemaps' toString, stop going through `Function.prototype.call`.
- Cold indexed loops that only read `xs[i]` read as
  `for (const x of drain(xs))` now. Hot paths stay indexed: the text
  layer, per-write attribute rules, instanceof, per-call IDL
  conversion, stack formatting and performance entry lists. So do live
  platform collections, which `drain` cannot be handed.
- The commented-out `window.postMessage` proxy from the wrapPostMessage
  era is deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AWUAE5wuAo6WKM7y5TncPC
This commit is contained in:
velzieandClaude Opus 5.5 committed 2026-09-24 16:45:15 -04:00
1 parent f7268b8c99
commit c5d59ce573
31 files changed
+631 -257

No files matched your search

@@ -8,6 +8,7 @@ import noGlobalsPlugin from "./tools/eslint/no-globals-plugin.mjs";
import noInstanceofPlugin from "./tools/eslint/no-instanceof-plugin.mjs";
import poisonedCtxPlugin from "./tools/eslint/poisoned-ctx-plugin.mjs";
import brandCheckPlugin from "./tools/eslint/brand-check-plugin.mjs";
import noUnsafeIterationPlugin from "./tools/eslint/no-unsafe-iteration-plugin.mjs";
const scramjetCorePlugin = {
rules: {
@@ -15,6 +16,7 @@ const scramjetCorePlugin = {
...noInstanceofPlugin.rules,
...poisonedCtxPlugin.rules,
...brandCheckPlugin.rules,
...noUnsafeIterationPlugin.rules,
},
};
@@ -99,9 +101,20 @@ export default [
},
},
{
// the page realm, where the iteration intrinsics are page-replaceable.
// `src/shared/` is deliberately not covered: most of it runs in the
// service worker, where there is no page script to replace anything
files: ["src/client/**/*.ts"],
rules: {
"scramjet-core/no-poisoned-ctx-value": "warn",
"scramjet-core/no-unsafe-iteration": "error",
},
},
{
// shared, but dispatched in the page on every navigation and pushState
files: ["src/Tap.ts"],
rules: {
"scramjet-core/no-unsafe-iteration": "error",
},
},
];
+55 -37
View File
@@ -1,4 +1,10 @@
import { Promise_all } from "@/shared/snapshot";
import {
Array_includes,
Array_map,
Object_create,
Promise_all,
drain,
} from "@/shared/snapshot";
type Description = {
context?: object;
@@ -43,50 +49,66 @@ function mergeTapOrder(plugin: Plugin, order?: TapOrder): TapOrder {
};
}
/**
* Order `callbacks` so each runs after the plugins it names in `after`, and
* before the ones it names in `before`.
*
* Dispatch happens in the page realm - every navigation and `pushState` goes
* through here - so this sticks to `drain` and indexed writes: `for...of`,
* spread and the `Array.prototype` methods all reach intrinsics the page can
* replace, and with them which hooks run, in what order.
*/
function sortCallbacks<T extends Description>(
callbacks: CallbackInfo<T>[]
): CallbackInfo<T>[] {
const afters: Record<string, string[]> = {};
for (const callback of callbacks) {
const afters: Record<string, string[]> = Object_create(null);
const addAfter = (plugin: string, after: string) => {
const list = (afters[plugin] ??= []);
if (!Array_includes(list, after)) list[list.length] = after;
};
for (const callback of drain(callbacks)) {
if (callback.order.before) {
for (const before of callback.order.before) {
afters[before] ??= [];
if (!afters[before].includes(callback.plugin.name)) {
afters[before].push(callback.plugin.name);
}
for (const before of drain(callback.order.before)) {
addAfter(before, callback.plugin.name);
}
}
if (callback.order.after) {
for (const after of callback.order.after) {
afters[callback.plugin.name] ??= [];
if (!afters[callback.plugin.name].includes(after)) {
afters[callback.plugin.name].push(after);
}
for (const after of drain(callback.order.after)) {
addAfter(callback.plugin.name, after);
}
}
}
const byName = (name: string) => {
for (const callback of drain(callbacks)) {
if (callback.plugin.name === name) return callback;
}
return undefined;
};
const sorted: CallbackInfo<T>[] = [];
function recurse(callback: CallbackInfo<T>, visited: string[]) {
if (afters[callback.plugin.name]) {
for (const after of afters[callback.plugin.name]) {
if (visited.includes(after)) {
throw `Circular dependency detected: ${callback.plugin.name} -> ${after}. Using append order.`;
}
const afterCallback = callbacks.find((c) => c.plugin.name === after);
if (afterCallback) {
recurse(afterCallback, [...visited, callback.plugin.name]);
// the chain of plugins being placed, innermost last
const visiting: string[] = [];
function recurse(callback: CallbackInfo<T>) {
const name = callback.plugin.name;
if (afters[name]) {
visiting[visiting.length] = name;
for (const after of drain(afters[name])) {
if (Array_includes(visiting, after)) {
throw `Circular dependency detected: ${name} -> ${after}. Using append order.`;
}
const afterCallback = byName(after);
if (afterCallback) recurse(afterCallback);
}
visiting.length--;
}
if (!sorted.includes(callback)) {
sorted.push(callback);
}
if (!Array_includes(sorted, callback)) sorted[sorted.length] = callback;
}
try {
for (const callback of callbacks) {
recurse(callback, []);
for (const callback of drain(callbacks)) {
recurse(callback);
}
return sorted;
} catch (err) {
@@ -117,13 +139,12 @@ export class Tap {
props: T["props"]
): Promise<void[]> | null {
const internal = hook as unknown as InternalHookDescription;
let callbacks = internal.tap.callbacks[internal.key];
const callbacks = internal.tap.callbacks[internal.key];
if (!callbacks || callbacks.length === 0) return null;
callbacks = sortCallbacks([...callbacks] as CallbackInfo<T>[]);
const sorted = sortCallbacks(callbacks as CallbackInfo<T>[]);
const results = callbacks.map((cb) => cb.callback(context, props));
return Promise_all(results);
return Promise_all(Array_map(sorted, (cb) => cb.callback(context, props)));
}
static tap<T extends Description>(
@@ -135,11 +156,8 @@ export class Tap {
const internal = hook as unknown as InternalHookDescription;
const callbacks = internal.tap.callbacks;
if (!callbacks[internal.key]) callbacks[internal.key] = [];
callbacks[internal.key]!.push({
callback,
plugin,
order,
});
const list = callbacks[internal.key]!;
list[list.length] = { callback, plugin, order };
}
static create<T extends Record<string, Description>>(): TapInstance<T> {
@@ -161,6 +179,6 @@ export class Tap {
static getTappers<T extends Description>(hook: T): Plugin[] {
const internal = hook as unknown as InternalHookDescription;
return internal.tap.callbacks[internal.key].map((c) => c.plugin);
return Array_map(internal.tap.callbacks[internal.key], (c) => c.plugin);
}
}
@@ -43,6 +43,7 @@ import {
String_startsWith,
String_substring,
String_toLowerCase,
drain,
} from "@/shared/snapshot";
/**
@@ -537,6 +538,9 @@ export class AttributeLayer {
callback = definition.prototype.attributeChangedCallback;
const observed = (definition as any).observedAttributes;
if (observed === undefined || observed === null) return;
// the page's own iterable, so its own iteration protocol is the one
// the browser would run too, when `define()` converts it
// eslint-disable-next-line scramjet-core/no-unsafe-iteration
for (const entry of observed as Iterable<unknown>) {
if (String(entry) === name) listed = true;
}
@@ -560,8 +564,8 @@ export class AttributeLayer {
).getAttributeNames();
let internal = false;
for (let i = 0; i < all.length; i++) {
if (isInternalAttribute(all[i])) {
for (const name of drain(all)) {
if (isInternalAttribute(name)) {
internal = true;
break;
}
@@ -569,8 +573,7 @@ export class AttributeLayer {
if (!internal) return all;
const out: string[] = [];
for (let i = 0; i < all.length; i++) {
const name = all[i];
for (const name of drain(all)) {
const mirrored = mirroredAttributeName(name);
// an ordinary attribute, in its own position
@@ -60,11 +60,13 @@ import {
Object_setPrototypeOf,
Object_assign,
Promise_then,
drain,
String_startsWith,
String_trim,
String_split,
String_toLowerCase,
_RegExp,
Array_includes,
} from "@/shared/snapshot";
import {
isConstructorMember,
@@ -182,8 +184,8 @@ export type ScramjetModule = {
};
function findBox(global: Window, seen: Window[]): SingletonBox | null {
if (seen.includes(global)) return null;
seen.push(global);
if (Array_includes(seen, global)) return null;
seen[seen.length] = global;
try {
if ((SCRAMJETCLIENT in global) as any) {
@@ -339,7 +341,9 @@ export class ScramjetClient {
Reflect_apply(fn, object, args),
});
} else if (desc.get) {
return desc.get.call(object);
// not `desc.get.call`: that looks `call` up on
// Function.prototype, which the page can replace
return Reflect_apply(desc.get, object, []);
}
},
set(_target, method: string, value: any) {
@@ -349,7 +353,7 @@ export class ScramjetClient {
`No native setter ${method.toString()} found for ${prototype}`
);
}
desc.set.call(object, value);
Reflect_apply(desc.set, object, [value]);
return true;
},
}
@@ -378,7 +382,7 @@ export class ScramjetClient {
* there is no setter to find and the key is defined outright.
*/
saveNatives() {
for (const key of Object_getOwnPropertyNames(this.global)) {
for (const key of drain(Object_getOwnPropertyNames(this.global))) {
const value = this.global[key];
if (typeof value === "function" && "prototype" in value) {
const natives = Object_create(null);
@@ -510,12 +514,20 @@ export class ScramjetClient {
if (!iswindow) return "";
// TODO: need to nullify the actual meta tag so it still sends unsafe-url
const nDoc = new client.native.Document(client.global.document);
const meta = [
...nDoc.querySelectorAll("meta[name='referrer']"),
...nDoc.querySelectorAll("meta[name='referrer-policy']"),
...nDoc.querySelectorAll("meta[http-equiv='referrer-policy']"),
];
const last = meta[meta.length - 1];
// only the last match counts, so look for it list by list from the
// back. Indexed, and not `drain`: a NodeList is a live platform
// collection, and spreading it ran the page-replaceable
// iteration protocol over what decides the referrer policy
let last: Element | undefined;
for (const selector of drain([
"meta[http-equiv='referrer-policy']",
"meta[name='referrer-policy']",
"meta[name='referrer']",
])) {
const list = nDoc.querySelectorAll(selector);
last = list[list.length - 1];
if (last) break;
}
if (last) {
const nLast = new client.native.HTMLMetaElement(last);
return nLast.getAttribute("content");
@@ -549,7 +561,7 @@ export class ScramjetClient {
const modules: ScramjetModule[] = [];
for (const key of context.keys()) {
for (const key of drain(context.keys())) {
if (!key.endsWith(".ts")) continue;
if (
(key.startsWith("./dom/") && "window" in this.global) ||
@@ -567,7 +579,7 @@ export class ScramjetClient {
return aorder - border;
});
for (const module of modules) {
for (const module of drain(modules)) {
// one module throwing used to abort the loop, so a single interface
// missing from this realm silently left every module after it
// uninstalled. a hooked-but-incomplete realm is bad; an unhooked one
@@ -804,8 +816,8 @@ export class ScramjetClient {
String_toLowerCase(sandbox),
new _RegExp("[\\t\\n\\f\\r ]+")
);
for (let i = 0; i < tokens.length; i++) {
if (tokens[i] === "allow-same-origin") return false;
for (const token of drain(tokens)) {
if (token === "allow-same-origin") return false;
}
return true;
@@ -814,6 +826,22 @@ export class ScramjetClient {
}
}
/**
* `"Element.prototype.innerHTML"` as the object that owns the last name
* and that name, walked from this client's global. Null when a step along
* the way is missing from this realm.
*/
private resolvePath(name: string): { owner: any; prop: string } | null {
const path = String_split(name, ".");
const prop = path[path.length - 1];
let owner: any = this.global;
for (let i = 0; i < path.length - 1; i++) {
owner = owner?.[path[i]];
}
return owner && prop ? { owner, prop } : null;
}
/**
* This document's origin as `postMessage` compares it: the serialized
* origin for a tuple origin, or for an opaque one a key unique to it -
@@ -856,20 +884,17 @@ export class ScramjetClient {
): void;
Proxy(name: string | string[], handler: Proxy<any>): void {
if (Array_isArray(name)) {
for (const n of name) {
for (const n of drain(name)) {
this.Proxy(n, handler);
}
return;
}
const split = name.split(".");
const prop = split.pop();
const target = split.reduce((a, b) => a?.[b], this.global);
const target = this.resolvePath(name);
if (!target) return;
if (!prop) return;
this.RawProxy(target, prop, handler, name);
this.RawProxy(target.owner, target.prop, handler, name);
}
/**
* A named `apply`/`construct` pair for one intercepted member.
@@ -1150,20 +1175,17 @@ return { apply, construct };
): void;
Trap(name: string | string[], descriptor: Trap<any>): void {
if (Array_isArray(name)) {
for (const n of name) {
for (const n of drain(name)) {
this.Trap(n, descriptor);
}
return;
}
const split = name.split(".");
const prop = split.pop();
const target = split.reduce((a, b) => a?.[b], this.global);
const target = this.resolvePath(name);
if (!target) return;
if (!prop) return;
this.RawTrap(target, prop, descriptor, name);
this.RawTrap(target.owner, target.prop, descriptor, name);
}
RawTrap(
target: any,
@@ -1460,13 +1482,13 @@ return { apply, construct };
return "value" in desc && desc.writable === false;
};
for (const prop of Reflect_ownKeys(prototypeDescs)) {
for (const prop of drain(Reflect_ownKeys(prototypeDescs))) {
const classDesc = prototypeDescs[prop];
if (isClassMetadata(prop, classDesc, false)) continue;
if (isConstructorMember(classDesc.value)) continue;
writePrototypeField(prop, baseclass.prototype, classDesc, true);
}
for (const prop of Reflect_ownKeys(staticDescs)) {
for (const prop of drain(Reflect_ownKeys(staticDescs))) {
const handlerDesc = staticDescs[prop];
if (isClassMetadata(prop, handlerDesc, true)) continue;
const value = handlerDesc.value;
@@ -12,6 +12,7 @@ import {
String_substring,
String_toLowerCase,
TypeError,
drain,
} from "@/shared/snapshot";
import { ScramjetClient } from "@client/client";
import {
@@ -77,9 +78,9 @@ export default function (client: ScramjetClient, _self: Self) {
const cookies = client.context.cookieJar.getCookieList(client.url, true);
const items: CookieListItem[] = [];
for (let i = 0; i < cookies.length; i++) {
if (name !== undefined && cookies[i].name !== name) continue;
items[items.length] = toCookieListItem(cookies[i]);
for (const cookie of drain(cookies)) {
if (name !== undefined && cookie.name !== name) continue;
items[items.length] = toCookieListItem(cookie);
}
return items;
@@ -37,6 +37,7 @@ import {
String,
Symbol_iterator,
_Map,
drain,
} from "@/shared/snapshot";
/**
@@ -127,14 +128,16 @@ export default function (client: ScramjetClient, _self: Self) {
return function* (this: NamedNodeMap) {
const owner = ownerOf(brand(this));
if (!owner) {
const iterator = new client.native.NamedNodeMap(real(this))[
Symbol_iterator
]();
for (const attr of iterator) yield attr;
// indexed through the native, not by running the native
// iterator: `for...of` over it reads `next` and
// @@iterator off prototypes the page can replace.
// `length` is read every step, as the live iterator does
const native = new client.native.NamedNodeMap(real(this));
for (let i = 0; i < native.length; i++) yield native.item(i)!;
return;
}
const nodes = attrs.nodes(owner);
for (let i = 0; i < nodes.length; i++) yield nodes[i];
for (const node of drain(nodes)) yield node;
};
}
@@ -166,7 +169,7 @@ export default function (client: ScramjetClient, _self: Self) {
ownKeys(target) {
const names = attrs.names(element);
const keys: (string | symbol)[] = [];
const seen = new _Map<string, true>([]);
const seen = new _Map<string, true>();
// an attribute named "0" is already listed as the index it shares
// its key with, and a key listed twice breaks the proxy invariant
@@ -174,18 +177,17 @@ export default function (client: ScramjetClient, _self: Self) {
keys[keys.length] = String(i);
seen.set(String(i), true);
}
for (let i = 0; i < names.length; i++) {
if (seen.get(names[i])) continue;
seen.set(names[i], true);
keys[keys.length] = names[i];
for (const name of drain(names)) {
if (seen.get(name)) continue;
seen.set(name, true);
keys[keys.length] = name;
}
// anything the native owns that is neither an index nor an
// attribute name. there is nothing in practice, but a key that
// exists and is left out of `ownKeys` is a proxy invariant
// violation waiting to happen
const own = Reflect_ownKeys(target);
for (let i = 0; i < own.length; i++) {
const key = own[i];
for (const key of drain(own)) {
if (typeof key !== "string") {
keys[keys.length] = key;
continue;
@@ -11,6 +11,7 @@ import {
_Map,
_Set,
_WeakMap,
drain,
} from "@/shared/snapshot";
import { Arguments, Returns, Type, idlDOMString } from "@client/webidl";
import { mirrorAttributeName } from "@client/attributes";
@@ -207,8 +208,8 @@ export default function (client: ScramjetClient, self: Self) {
const names = new _Set<string>();
const own = Object_getOwnPropertyNames(style);
for (let i = 0; i < own.length; i++) {
if (!isIndex(own[i])) names.add(own[i]);
for (const name of drain(own)) {
if (!isIndex(name)) names.add(name);
}
cssAttributesByKind.set(kind, names);
@@ -384,11 +385,18 @@ export default function (client: ScramjetClient, self: Self) {
* element hands out writes that element's style attribute.
*/
if ("StylePropertyMap" in self) {
const rewriteValues = (values: (CSSStyleValue | string)[]) => {
const out: (CSSStyleValue | string)[] = [];
// `(property, ...values)` as one array for `Reflect_apply`, since
// spreading it into the call would run the page-replaceable iteration
// protocol over values that were just rewritten
const rewriteArgs = (
property: string,
values: (CSSStyleValue | string)[]
) => {
const out: (CSSStyleValue | string)[] = [property];
for (let i = 0; i < values.length; i++) {
const value = values[i];
out[i] = typeof value === "string" && value ? rewrite(value) : value;
out[i + 1] =
typeof value === "string" && value ? rewrite(value) : value;
}
return out;
@@ -398,14 +406,14 @@ export default function (client: ScramjetClient, self: Self) {
@Arguments("USVString", "(CSSStyleValue or USVString)...")
@Returns("undefined")
set(property: string, ...values: (CSSStyleValue | string)[]): void {
super.set(property, ...rewriteValues(values));
Reflect_apply(super.set, this, rewriteArgs(property, values));
touched(this);
}
@Arguments("USVString", "(CSSStyleValue or USVString)...")
@Returns("undefined")
append(property: string, ...values: (CSSStyleValue | string)[]): void {
super.append(property, ...rewriteValues(values));
Reflect_apply(super.append, this, rewriteArgs(property, values));
touched(this);
}
@@ -1,7 +1,12 @@
import { rewriteHtml } from "@rewriters/html";
import { ScramjetClient } from "@client/index";
import { ForeignContext } from "@/shared/rewriters/html";
import { Array_indexOf, String, String_substring } from "@/shared/snapshot";
import {
Array_indexOf,
String,
String_substring,
drain,
} from "@/shared/snapshot";
import { Arguments, Returns, Type } from "@client/webidl";
import { foreignContextForElement } from "@client/dom/markup";
@@ -144,8 +149,8 @@ export default function (client: ScramjetClient, _self: Self) {
new client.native.Range(self).startContainer,
new client.native.Range(self).endContainer,
];
for (let i = 0; i < ends.length; i++) {
for (let n: Node | null = ends[i]; n && n !== common; n = parentOf(n)) {
for (const end of drain(ends)) {
for (let n: Node | null = end; n && n !== common; n = parentOf(n)) {
if (!isText(n)) return true;
}
}
@@ -278,7 +283,7 @@ export default function (client: ScramjetClient, _self: Self) {
const parts = covered(this, intersecting(this, root));
let out = "";
for (let i = 0; i < parts.length; i++) out += parts[i];
for (const part of drain(parts)) out += part;
return out;
}
@@ -366,7 +371,7 @@ export default function (client: ScramjetClient, _self: Self) {
text.record(nodes[i], String_substring(page, endOffset));
}
}
for (let i = 0; i < owners.length; i++) text.sync(owners[i]);
for (const owner of drain(owners)) text.sync(owner);
return fragment;
}
@@ -16,6 +16,8 @@ import {
String,
String_substring,
String_toLowerCase,
Reflect_apply,
drain,
} from "@/shared/snapshot";
/** Node types, as `Node.prototype.nodeType` reports them. */
@@ -257,13 +259,13 @@ export default function (client: ScramjetClient, _self: Self) {
// back into the rewritten whole
const self = this as Node;
const affected = rawTextInclusiveDescendants(self);
for (let i = 0; i < affected.length; i++) {
text.normalizeChildren(affected[i]);
for (const node of drain(affected)) {
text.normalizeChildren(node);
}
super.normalize();
for (let i = 0; i < affected.length; i++) text.sync(affected[i]);
for (const node of drain(affected)) text.sync(node);
}
});
@@ -276,7 +278,7 @@ export default function (client: ScramjetClient, _self: Self) {
void super.hasAttributes();
const args = asNodes(this, nodes) as (Node | string)[];
text.around(this, args, () => super.append(...args));
text.around(this, args, () => Reflect_apply(super.append, this, args));
}
@Arguments("(Node or DOMString)...")
@@ -285,7 +287,7 @@ export default function (client: ScramjetClient, _self: Self) {
void super.hasAttributes();
const args = asNodes(this, nodes) as (Node | string)[];
text.around(this, args, () => super.prepend(...args));
text.around(this, args, () => Reflect_apply(super.prepend, this, args));
}
@Arguments("(Node or DOMString)...")
@@ -294,7 +296,9 @@ export default function (client: ScramjetClient, _self: Self) {
void super.hasAttributes();
const args = asNodes(this, nodes) as (Node | string)[];
text.around(this, args, () => super.replaceChildren(...args));
text.around(this, args, () =>
Reflect_apply(super.replaceChildren, this, args)
);
}
@Arguments("(Node or DOMString)...")
@@ -302,7 +306,7 @@ export default function (client: ScramjetClient, _self: Self) {
after(...nodes: (Node | string)[]): void {
const parent = super.parentNode;
const args = asNodes(parent, nodes) as (Node | string)[];
text.around(parent, args, () => super.after(...args));
text.around(parent, args, () => Reflect_apply(super.after, this, args));
}
@Arguments("(Node or DOMString)...")
@@ -310,7 +314,7 @@ export default function (client: ScramjetClient, _self: Self) {
before(...nodes: (Node | string)[]): void {
const parent = super.parentNode;
const args = asNodes(parent, nodes) as (Node | string)[];
text.around(parent, args, () => super.before(...args));
text.around(parent, args, () => Reflect_apply(super.before, this, args));
}
@Arguments("(Node or DOMString)...")
@@ -318,7 +322,9 @@ export default function (client: ScramjetClient, _self: Self) {
replaceWith(...nodes: (Node | string)[]): void {
const parent = super.parentNode;
const args = asNodes(parent, nodes) as (Node | string)[];
text.around(parent, args, () => super.replaceWith(...args));
text.around(parent, args, () =>
Reflect_apply(super.replaceWith, this, args)
);
}
@Arguments("DOMString", "DOMString")
@@ -379,7 +385,7 @@ export default function (client: ScramjetClient, _self: Self) {
append(...nodes: (Node | string)[]): void {
void super.childElementCount;
text.around(this, nodes, () => super.append(...nodes));
text.around(this, nodes, () => Reflect_apply(super.append, this, nodes));
}
@Arguments("(Node or DOMString)...")
@@ -387,7 +393,7 @@ export default function (client: ScramjetClient, _self: Self) {
prepend(...nodes: (Node | string)[]): void {
void super.childElementCount;
text.around(this, nodes, () => super.prepend(...nodes));
text.around(this, nodes, () => Reflect_apply(super.prepend, this, nodes));
}
@Arguments("(Node or DOMString)...")
@@ -395,7 +401,9 @@ export default function (client: ScramjetClient, _self: Self) {
replaceChildren(...nodes: (Node | string)[]): void {
void super.childElementCount;
text.around(this, nodes, () => super.replaceChildren(...nodes));
text.around(this, nodes, () =>
Reflect_apply(super.replaceChildren, this, nodes)
);
}
@Arguments("Node", "Node?")
@@ -413,7 +421,7 @@ export default function (client: ScramjetClient, _self: Self) {
append(...nodes: (Node | string)[]): void {
void super.childElementCount;
text.around(this, nodes, () => super.append(...nodes));
text.around(this, nodes, () => Reflect_apply(super.append, this, nodes));
}
@Arguments("(Node or DOMString)...")
@@ -421,7 +429,7 @@ export default function (client: ScramjetClient, _self: Self) {
prepend(...nodes: (Node | string)[]): void {
void super.childElementCount;
text.around(this, nodes, () => super.prepend(...nodes));
text.around(this, nodes, () => Reflect_apply(super.prepend, this, nodes));
}
@Arguments("(Node or DOMString)...")
@@ -429,7 +437,9 @@ export default function (client: ScramjetClient, _self: Self) {
replaceChildren(...nodes: (Node | string)[]): void {
void super.childElementCount;
text.around(this, nodes, () => super.replaceChildren(...nodes));
text.around(this, nodes, () =>
Reflect_apply(super.replaceChildren, this, nodes)
);
}
@Arguments("Node", "Node?")
@@ -550,7 +560,7 @@ export default function (client: ScramjetClient, _self: Self) {
after(...nodes: (Node | string)[]): void {
const parent = super.parentNode;
const args = asNodes(parent, nodes) as (Node | string)[];
text.around(parent, args, () => super.after(...args));
text.around(parent, args, () => Reflect_apply(super.after, this, args));
}
@Arguments("(Node or DOMString)...")
@@ -558,7 +568,7 @@ export default function (client: ScramjetClient, _self: Self) {
before(...nodes: (Node | string)[]): void {
const parent = super.parentNode;
const args = asNodes(parent, nodes) as (Node | string)[];
text.around(parent, args, () => super.before(...args));
text.around(parent, args, () => Reflect_apply(super.before, this, args));
}
@Arguments("(Node or DOMString)...")
@@ -566,7 +576,9 @@ export default function (client: ScramjetClient, _self: Self) {
replaceWith(...nodes: (Node | string)[]): void {
const parent = super.parentNode;
const args = asNodes(parent, nodes) as (Node | string)[];
text.around(parent, args, () => super.replaceWith(...args));
text.around(parent, args, () =>
Reflect_apply(super.replaceWith, this, args)
);
}
@Arguments()
@@ -1,5 +1,10 @@
import { ScramjetClient } from "@client/index";
import { String, String_endsWith, String_startsWith } from "@/shared/snapshot";
import {
String,
String_endsWith,
String_startsWith,
drain,
} from "@/shared/snapshot";
import { Arguments, Returns, Type } from "@client/webidl";
export default function (client: ScramjetClient) {
@@ -37,8 +42,8 @@ export default function (client: ScramjetClient) {
const name = visibleName(nativeName(entry));
const masked = client.config.maskedfiles;
for (let i = 0; i < masked.length; i++) {
if (String_endsWith(name, masked[i])) return true;
for (const file of drain(masked)) {
if (String_endsWith(name, file)) return true;
}
return false;
@@ -26,7 +26,13 @@ import { ScramjetClient } from "@client/index";
import { Arguments, Returns, Type, idlUSVString } from "@client/webidl";
import { unrewriteUrl } from "@rewriters/url";
import { XLINK_NAMESPACE } from "@client/attributes";
import { String, String_startsWith, _URL } from "@/shared/snapshot";
import {
String,
String_startsWith,
_URL,
Reflect_apply,
drain,
} from "@/shared/snapshot";
export default function (client: ScramjetClient, self: Self) {
const attrs = client.attributes;
@@ -501,14 +507,14 @@ export default function (client: ScramjetClient, self: Self) {
@Arguments("DOMString...")
@Returns("undefined")
add(...tokens: string[]): void {
super.add(...tokens);
Reflect_apply(super.add, this, tokens);
syncSandbox(this);
}
@Arguments("DOMString...")
@Returns("undefined")
remove(...tokens: string[]): void {
super.remove(...tokens);
Reflect_apply(super.remove, this, tokens);
syncSandbox(this);
}
@@ -1378,7 +1384,7 @@ export default function (client: ScramjetClient, self: Self) {
"SVGFilterElement",
];
for (const name of SVG_URI_REFERENCES) {
for (const name of drain(SVG_URI_REFERENCES)) {
client.Trap(`${name}.prototype.href`, {
get(ctx) {
const animated = ctx.get() as SVGAnimatedString;
@@ -13,6 +13,7 @@ import {
String_substring,
_Set,
_WeakMap,
drain,
} from "@/shared/snapshot";
export const enabled = (_client: ScramjetClient, self: Self) =>
@@ -57,8 +58,8 @@ export default function (client: ScramjetClient, self: Self) {
const all = Object_keys(area);
const mine: string[] = [];
for (let i = 0; i < all.length; i++) {
if (String_startsWith(all[i], scope)) mine[mine.length] = all[i];
for (const key of drain(all)) {
if (String_startsWith(key, scope)) mine[mine.length] = key;
}
return mine;
@@ -139,7 +140,7 @@ export default function (client: ScramjetClient, self: Self) {
// this site's entries only, never the whole area. `Object_keys`
// snapshots, so removing while iterating is safe
for (const key of scopedKeys(area)) {
for (const key of drain(scopedKeys(area))) {
nArea.removeItem(key);
}
}
@@ -160,8 +161,8 @@ export default function (client: ScramjetClient, self: Self) {
proto = Object_getPrototypeOf(proto)
) {
const keys = Reflect_ownKeys(proto);
for (let i = 0; i < keys.length; i++) {
if (typeof keys[i] === "string") memberNames.add(keys[i] as string);
for (const key of drain(keys)) {
if (typeof key === "string") memberNames.add(key as string);
}
}
@@ -221,8 +222,7 @@ export default function (client: ScramjetClient, self: Self) {
const keys: string[] = [];
const own = Reflect_ownKeys(target);
for (let i = 0; i < own.length; i++) {
const key = own[i];
for (const key of drain(own)) {
if (typeof key !== "string" || !String_startsWith(key, scope)) continue;
keys[keys.length] = String_substring(key, scope.length);
}
@@ -7,6 +7,7 @@ import {
_URL,
Object_defineProperty,
Object_getOwnPropertyDescriptor,
drain,
} from "@/shared/snapshot";
export function createLocationProxy(client: ScramjetClient, self: GlobalThis) {
@@ -29,7 +30,7 @@ export function createLocationProxy(client: ScramjetClient, self: GlobalThis) {
"port",
"search",
];
for (const prop of urlprops) {
for (const prop of drain(urlprops)) {
const native = Object_getOwnPropertyDescriptor(descriptorSource, prop);
if (!native) continue;
@@ -8,6 +8,7 @@ import {
String_trim,
_Set,
_URL,
drain,
} from "@/shared/snapshot";
export const enabled = (_client: ScramjetClient, self: Self) =>
@@ -41,8 +42,8 @@ export default function (client: ScramjetClient, self: Self) {
if (vary === null) return false;
const parts = String_split(vary, ",");
for (let i = 0; i < parts.length; i++) {
if (String_trim(parts[i]) === "*") return true;
for (const part of drain(parts)) {
if (String_trim(part) === "*") return true;
}
return false;
@@ -135,8 +136,8 @@ export default function (client: ScramjetClient, self: Self) {
method: "add" | "addAll",
put: (key: RequestInfo, response: Response) => Promise<void>
): Promise<void> => {
for (let i = 0; i < requests.length; i++) {
const url = realUrl(requests[i]);
for (const request of drain(requests)) {
const url = realUrl(request);
if (
!String_startsWith(url, "http:") &&
!String_startsWith(url, "https:")
@@ -150,7 +151,7 @@ export default function (client: ScramjetClient, self: Self) {
// a cache key is always a GET, and the spec refuses the batch here
// rather than letting `put` reject it one entry in
if (methodOf(requests[i]) !== "GET") {
if (methodOf(request) !== "GET") {
throw client.errors.typeError({
execute: method,
on: "Cache",
@@ -171,8 +172,8 @@ export default function (client: ScramjetClient, self: Self) {
}
const responses = await Promise_all(fetches);
for (let i = 0; i < responses.length; i++) {
const status = new client.native.Response(responses[i]).status;
for (const response of drain(responses)) {
const status = new client.native.Response(response).status;
// `status` through the native rather than `ok`, which is a
// page-replaceable accessor on `Response.prototype`. 206 is inside
@@ -187,7 +188,7 @@ export default function (client: ScramjetClient, self: Self) {
});
}
if (variesOnEverything(responses[i])) {
if (variesOnEverything(response)) {
throw client.errors.typeError({
execute: method,
on: "Cache",
@@ -197,8 +198,8 @@ export default function (client: ScramjetClient, self: Self) {
}
const seen = new _Set<string>();
for (let i = 0; i < requests.length; i++) {
const url = realUrl(requests[i]);
for (const request of drain(requests)) {
const url = realUrl(request);
if (seen.has(url)) {
throw client.errors.domException("InvalidStateError", {
execute: method,
@@ -245,7 +246,7 @@ export default function (client: ScramjetClient, self: Self) {
request === undefined ? undefined : cacheKey(request),
options
);
for (const match of matches) tag(match);
for (const match of drain(matches)) tag(match);
return matches;
}
@@ -296,8 +297,8 @@ export default function (client: ScramjetClient, self: Self) {
: super.keys(cacheKey(request), options));
const visible: Request[] = [];
for (let i = 0; i < stored.length; i++) {
visible[visible.length] = pageKey(stored[i]);
for (const key of drain(stored)) {
visible[visible.length] = pageKey(key);
}
return visible;
@@ -383,13 +384,13 @@ export default function (client: ScramjetClient, self: Self) {
const names = await super.keys();
const prefix = scopePrefix();
for (let i = 0; i < names.length; i++) {
if (!String_startsWith(names[i], prefix)) continue;
for (const name of drain(names)) {
if (!String_startsWith(name, prefix)) continue;
// sequential on purpose - the spec's answer is the first match
// in insertion order, so a later cache must not be able to win
// eslint-disable-next-line no-await-in-loop
const cache = await super.open(names[i]);
const cache = await super.open(name);
// through the native rather than the page-visible
// `Cache.prototype.match`, which would run `cacheKey` a second
// time over a key that has already been through it
@@ -417,9 +418,9 @@ export default function (client: ScramjetClient, self: Self) {
const prefix = scopePrefix();
const visible: string[] = [];
for (let i = 0; i < names.length; i++) {
if (String_startsWith(names[i], prefix)) {
visible[visible.length] = String_substring(names[i], prefix.length);
for (const name of drain(names)) {
if (String_startsWith(name, prefix)) {
visible[visible.length] = String_substring(name, prefix.length);
}
}
@@ -3,15 +3,16 @@
import { iswindow } from "@client/entry";
import { ScramjetClient } from "@client/index";
import { String_split } from "@/shared/snapshot";
// type self as any here, most of these are not defined in the types
export default function (client: ScramjetClient, self: any) {
const del = (name: string) => {
const split = name.split(".");
const prop = split.pop();
const target = split.reduce((a, b) => a?.[b], self);
if (!target) return;
if (prop && prop in target) delete target[prop];
const path = String_split(name, ".");
const prop = path[path.length - 1];
let target = self;
for (let i = 0; i < path.length - 1; i++) target = target?.[path[i]];
if (target && prop in target) delete target[prop];
};
// obviously
@@ -3,6 +3,7 @@ import {
Object_getOwnPropertyDescriptor,
Reflect_apply,
Symbol_iterator,
Array_includes,
} from "@/shared/snapshot";
export const enabled = (client: ScramjetClient) =>
@@ -23,8 +24,8 @@ export function argdbg(arg, recurse = []) {
if (desc && desc.get) continue;
const ar = arg[prop];
if (recurse.includes(ar)) continue;
recurse.push(ar);
if (Array_includes(recurse, ar)) continue;
recurse[recurse.length] = ar;
argdbg(ar, recurse);
}
break;
@@ -17,6 +17,8 @@ import {
String_substring,
_Map,
_WeakMap,
drain,
Array_includes,
} from "@/shared/snapshot";
export default function (client: ScramjetClient, self: Self) {
@@ -459,8 +461,8 @@ export default function (client: ScramjetClient, self: Self) {
const ontargets = (): object[] => {
const found: object[] = [self.self];
for (const name of Object_getOwnPropertyNames(self)) {
if (synthetic.indexOf(name) !== -1) continue;
for (const name of drain(Object_getOwnPropertyNames(self))) {
if (Array_includes(synthetic, name)) continue;
const descriptor = Object_getOwnPropertyDescriptor(self, name);
if (!descriptor || typeof descriptor.value !== "function") continue;
@@ -474,9 +476,9 @@ export default function (client: ScramjetClient, self: Self) {
const handlertypes = Object_keys(handlers);
for (const target of ontargets()) {
for (let i = 0; i < handlertypes.length; i++) {
const key = "on" + handlertypes[i];
for (const target of drain(ontargets())) {
for (const type of drain(handlertypes)) {
const key = "on" + type;
const descriptor = Object_getOwnPropertyDescriptor(target, key);
if (!descriptor || !descriptor.get || !descriptor.set) continue;
@@ -4,6 +4,7 @@ import {
String_indexOf,
String_startsWith,
String_substring,
drain,
} from "@/shared/snapshot";
export const enabled = (_client: ScramjetClient, self: Self) =>
@@ -51,15 +52,15 @@ export default function (client: ScramjetClient) {
const prefix = `${client.scopeOrigin}@`;
const visible: IDBDatabaseInfo[] = [];
for (let i = 0; i < all.length; i++) {
const name = all[i].name;
for (const database of drain(all)) {
const name = database.name;
// a database with no name is not one of ours and cannot be
// attributed to this origin
if (name === undefined || !String_startsWith(name, prefix)) continue;
visible[visible.length] = {
name: String_substring(name, prefix.length),
version: all[i].version,
version: database.version,
};
}
@@ -227,37 +227,6 @@ export default function (client: ScramjetClient, self: Self) {
new client.native.EventTarget(self).addEventListener("message", gate, true);
}
// if (iswindow)
// client.Proxy("window.postMessage", {
// apply(ctx) {
// // console.log(
// // callerClient,
// // client,
// // callerGlobalThisProxied.document,
// // self.document,
// // callerClient === client
// // );
// const inherit =
// callerClient.url.href === "about:srcdoc" ||
// callerClient.url.href === "about:blank";
// ctx.args[0] = {
// $scramjet$messagetype: "window",
// $scramjet$origin: inherit
// ? callerClient.global.parent[SCRAMJETCLIENT].url.origin
// : callerClient.url.origin,
// $scramjet$data: ctx.args[0],
// };
// // console.error("?", ctx.args);
// // eval("debugger");
// // * origin because obviously
// if (typeof ctx.args[1] === "string") ctx.args[1] = "*";
// if (typeof ctx.args[1] === "object") ctx.args[1].targetOrigin = "*";
// ctx.return(wrappedPostMessage.call(ctx.fn, ...ctx.args));
// },
// });
client.Proxy("BroadcastChannel.prototype.postMessage", {
apply(ctx) {
ctx.args[0] = {
@@ -6,6 +6,7 @@ import {
String_startsWith,
String_substring,
String_toLowerCase,
drain,
} from "@/shared/snapshot";
import { carriedHeaderName, uncarriedHeaderName } from "@/shared/headers";
import { ScramjetClient } from "@client/client";
@@ -74,20 +75,18 @@ export default function (client: ScramjetClient) {
const restored: { name: string; line: string }[] = [];
const lines = String_split(raw, "\r\n");
for (let i = 0; i < lines.length; i++) {
const colon = String_indexOf(lines[i], ":");
for (const line of drain(lines)) {
const colon = String_indexOf(line, ":");
if (colon === -1) continue;
const carried = uncarriedHeaderName(
String_substring(lines[i], 0, colon)
);
const carried = uncarriedHeaderName(String_substring(line, 0, colon));
if (carried === null) continue;
const name = String_toLowerCase(carried);
// the value keeps the separator and its leading space verbatim
restored[restored.length] = {
name,
line: name + String_substring(lines[i], colon),
line: name + String_substring(line, colon),
};
}
@@ -12,6 +12,7 @@ import {
Reflect_apply,
Reflect_get,
String_startsWith,
drain,
} from "@/shared/snapshot";
/** The init members a string-input `fetch()` / `new Request()` reads, sorted. */
@@ -42,12 +43,23 @@ export default function (client: ScramjetClient, self: Self) {
const toNativeHeaders = (headers: Headers) => {
const nGlobal = new client.native.window(self);
const nHeaders = new client.native.Headers(headers);
const newHeaders = new nGlobal.Headers();
for (const [key, value] of nHeaders.entries()) {
const nNew = new client.native.Headers(newHeaders);
// `forEach`, which the native drives itself, rather than
// `for (const [key, value] of nHeaders.entries())`. Reaching the
// headers through `client.native` bought nothing there, because the
// iteration never touched the native: the loop read @@iterator off the
// iterator, which resolves to the page-replaceable `%IteratorPrototype%`,
// and the destructuring read `Array.prototype[@@iterator]`. A page could
// therefore choose what the restored view contained - and that view is
// what `new Headers(init)`, `new Request(url, {headers})` and
// `new Response(body, {headers})` are filled from
new client.native.Headers(headers).forEach((value: string, key: string) => {
const original = uncarriedHeaderName(key);
if (original !== null) newHeaders.set(original, value);
}
if (original !== null) nNew.set(original, value);
});
return newHeaders;
};
@@ -100,8 +112,8 @@ export default function (client: ScramjetClient, self: Self) {
return { init, members };
}
for (let i = 0; i < keys.length; i++) {
members[keys[i]] = (init as Record<string, unknown>)[keys[i]];
for (const key of drain(keys)) {
members[key] = (init as Record<string, unknown>)[key];
}
if (members.mode !== undefined) members.mode = idlDOMString(members.mode);
@@ -1,9 +1,25 @@
import { rewriteJs } from "@rewriters/js";
import { GlobalScope, ScramjetClient } from "@client/index";
import { String, TextDecoder_decode } from "@/shared/snapshot";
import { String, TextDecoder_decode, Reflect_apply } from "@/shared/snapshot";
import { Arguments, Returns } from "@client/webidl";
export default function (client: ScramjetClient, _self: Self) {
/**
* `(handler, timeout, ...args)` as one array for `Reflect_apply`. Spreading
* `args` into the call would run the page-replaceable iteration protocol,
* so the timer need not be called with what the coercion approved.
*/
const timerArguments = (
handler: TimerHandler,
timeout: number | undefined,
args: any[]
) => {
const call: any[] = [handler, timeout];
for (let i = 0; i < args.length; i++) call[i + 2] = args[i];
return call;
};
const rewriteHandler = (handler: TimerHandler): TimerHandler => {
if (typeof handler === "function") return handler;
@@ -35,10 +51,10 @@ export default function (client: ScramjetClient, _self: Self) {
// receiver silently made `setTimeout.call({}, fn, 0)` work; and a
// bare call passes undefined, which WebIDL sends to the global for
// a member of a [Global] interface, so that still resolves
return new client.native.window(this).setTimeout(
rewriteHandler(handler),
timeout,
...args
return Reflect_apply(
new client.native.window(this).setTimeout,
null,
timerArguments(rewriteHandler(handler), timeout, args)
);
}
@@ -49,10 +65,10 @@ export default function (client: ScramjetClient, _self: Self) {
timeout?: number,
...args: any[]
): number {
return new client.native.window(this).setInterval(
rewriteHandler(handler),
timeout,
...args
return Reflect_apply(
new client.native.window(this).setInterval,
null,
timerArguments(rewriteHandler(handler), timeout, args)
);
}
});
@@ -3,6 +3,7 @@ import {
Number_isSafeInteger,
Error,
TextDecoder_decode,
Reflect_apply,
} from "@/shared/snapshot";
import { base64ToBytes } from "@/shared/util";
import { ProxyCtx, ScramjetClient } from "@client/index";
@@ -123,11 +124,15 @@ function doUnrewrite(
client: ScramjetClient,
ctx: ProxyCtx<"Function.prototype.toString", "apply">
) {
const stringified: string = ctx.fn.call(ctx.this);
// not `ctx.fn.call`, which looks `call` up on the page-writable
// Function.prototype
const stringified: string = Reflect_apply(ctx.fn, ctx.this, []);
const extracted = extractTag(stringified);
if (!extracted) return ctx.return(stringified);
const [tag, tagOffset, tagStart] = extracted;
const tag = extracted[0];
const tagOffset = extracted[1];
const tagStart = extracted[2];
const fnStart = tagStart - tagOffset;
const fnEnd = fnStart + stringified.length;
@@ -151,12 +156,13 @@ function doUnrewrite(
if (getEnd(rewrites[end]) < fnEnd) end++;
else break;
}
const fnrewrites = rewrites.slice(i, end);
let newString = "";
let lastpos = 0;
for (const rewrite of fnrewrites) {
// indexed over the range rather than a `slice` to `for...of`: this runs
// for every `toString` of a rewritten function
for (; i < end; i++) {
const rewrite = rewrites[i];
newString += stringified.slice(lastpos, rewrite.start - fnStart);
if (rewrite.type === RewriteType.Insert) {
@@ -10,24 +10,25 @@ import {
_WeakSet,
Object_create,
Function_hasInstance,
drain,
} from "@/shared/snapshot";
import { FakeWebSocketState } from "./shared/requests/WebSocket";
import { FakeWebSocketStreamState } from "./shared/requests/WebSocketStream";
export class SingletonBox {
clients: ScramjetClient[] = [];
clientIds: _Map<string, ScramjetClient> = new _Map([]);
globals: _Map<Self, ScramjetClient> = new _Map([]);
documents: _Map<Document, ScramjetClient> = new _Map([]);
histories: _Map<History, ScramjetClient> = new _Map([]);
objectPrototypes: _Map<object, ScramjetClient> = new _Map([]);
locations: _Map<Location, ScramjetClient> = new _Map([]);
functions: _Map<typeof Function, ScramjetClient> = new _Map([]);
clientIds: _Map<string, ScramjetClient> = new _Map();
globals: _Map<Self, ScramjetClient> = new _Map();
documents: _Map<Document, ScramjetClient> = new _Map();
histories: _Map<History, ScramjetClient> = new _Map();
objectPrototypes: _Map<object, ScramjetClient> = new _Map();
locations: _Map<Location, ScramjetClient> = new _Map();
functions: _Map<typeof Function, ScramjetClient> = new _Map();
writeRewriters: _WeakMap<Document, IncrementalHtmlRewriter> = new _WeakMap(
[]
);
taggedHeaders: _WeakSet<Headers> = new _WeakSet([]);
taggedResponses: _WeakSet<Response> = new _WeakSet([]);
taggedHeaders: _WeakSet<Headers> = new _WeakSet();
taggedResponses: _WeakSet<Response> = new _WeakSet();
scopedOpfsRoots: _WeakSet<FileSystemHandle> = new _WeakSet();
styleDeclarations: _WeakMap<CSSStyleDeclaration, CSSStyleDeclaration> =
new _WeakMap();
@@ -131,9 +132,9 @@ export class SingletonBox {
unproxy: _WeakMap<object, any> = new _WeakMap();
socketmap: _WeakMap<WebSocket, FakeWebSocketState> = new _WeakMap([]);
socketmap: _WeakMap<WebSocket, FakeWebSocketState> = new _WeakMap();
socketstreammap: _WeakMap<WebSocketStream, FakeWebSocketStreamState> =
new _WeakMap([]);
new _WeakMap();
ctors: Record<string, ((...args: any[]) => any)[]> = Object_create(null);
@@ -185,8 +186,7 @@ export class SingletonBox {
this.clientIds.set(client.id, client);
const names = Object_getOwnPropertyNames(global);
for (let i = 0; i < names.length; i++) {
const prop = names[i];
for (const prop of drain(names)) {
const desc = Object_getOwnPropertyDescriptor(global, prop);
if (desc && typeof desc.value === "function") {
let ctors = this.ctors[prop];
@@ -208,8 +208,11 @@ export class SingletonBox {
// not `instanceof`, which would run a page-defined
// `Symbol.hasInstance` - and callers use the answer to decide whether a
// value gets rewritten
for (const ctor of ctors) {
if (Function_hasInstance(ctor, obj)) return true;
// indexed rather than `for...of` or `drain`: this is the brand check
// every interceptor gates on, the hottest path in the client, and the
// bare protocol would have let a page decide its answer
for (let i = 0; i < ctors.length; i++) {
if (Function_hasInstance(ctors[i], obj)) return true;
}
return false;
}
@@ -54,6 +54,8 @@ import {
Symbol_iterator,
TypeError,
Error,
Array_includes,
drain,
} from "@/shared/snapshot";
import type { AnyFunction } from "@/types";
@@ -680,7 +682,7 @@ export type IDLMemberSignature = {
// keyed on the function object rather than `context.metadata`, which swc
// doesn't implement for 2022-03 decorators
const signatures = new _WeakMap([]) as WeakMap<AnyFunction, IDLMemberSignature>;
const signatures = new _WeakMap() as WeakMap<AnyFunction, IDLMemberSignature>;
function record(fn: AnyFunction, patch: IDLMemberSignature) {
const existing = signatures.get(fn);
@@ -1175,11 +1177,11 @@ function compileIDLUnion(box: IDLBrandChecker, inner: string): IDLCoerce {
const interfaces: string[] = [];
let fallback: IDLCoerce | undefined;
for (let i = 0; i < members.length; i++) {
const raw = members[i].trim();
for (const member of drain(members)) {
const raw = member.trim();
const name = stripIDLExtendedAttributes(raw);
if (IDL_STRING_TYPES.indexOf(name) !== -1) {
if (Array_includes(IDL_STRING_TYPES, name)) {
// two string members can't be told apart, so give up
if (fallback) return idlPassthrough;
fallback = compileIDLType(box, raw);
@@ -1194,7 +1196,7 @@ function compileIDLUnion(box: IDLBrandChecker, inner: string): IDLCoerce {
// an interface this realm does not have at all: nothing can be an
// instance of it, so the member is dropped and the rest of the union
// still gets converted
if (IDL_OPTIONAL_INTERFACES.indexOf(name) !== -1) continue;
if (Array_includes(IDL_OPTIONAL_INTERFACES, name)) continue;
// a dictionary, enum, sequence, numeric or unknown member — the spec's
// disambiguation rules for those need more type information than we have
@@ -1222,7 +1224,7 @@ function compileIDLUnion(box: IDLBrandChecker, inner: string): IDLCoerce {
* name boundary, and `"DOMString..."` has no name at all.
*/
function stripIDLArgumentName(s: string): string {
if (IDL_MULTIWORD_PRIMITIVES.indexOf(s) !== -1) return s;
if (Array_includes(IDL_MULTIWORD_PRIMITIVES, s)) return s;
const space = s.lastIndexOf(" ");
if (space <= 0) return s;
@@ -1367,7 +1369,7 @@ function compileIDLType(
if (enforceRange) return idlPassthrough;
// the extended attribute is only ever spec'd on string types
if (nullToEmpty && IDL_STRING_TYPES.indexOf(s) !== -1) {
if (nullToEmpty && Array_includes(IDL_STRING_TYPES, s)) {
return (value) => (value === null ? "" : primitive(value));
}
@@ -1881,12 +1883,11 @@ export function dictionaryReader<
): (value: unknown) => IDLDictionaryOf<P & M> {
const keys = sortedIDLMemberNames(inherited ?? {});
const own = sortedIDLMemberNames(members);
for (let i = 0; i < own.length; i++) keys[keys.length] = own[i];
for (const key of drain(own)) keys[keys.length] = key;
const all: Record<string, string> = { ...inherited, ...members };
const compiled: CompiledMember[] = [];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
for (const key of drain(keys)) {
const raw = String_trim(all[key]);
let declaration = raw;
@@ -1,5 +1,5 @@
import { GlobalScope, ScramjetClient } from "@client/index";
import { String } from "@/shared/snapshot";
import { String, Reflect_apply } from "@/shared/snapshot";
import { Arguments, Returns, idlUSVString } from "@client/webidl";
export default function (client: ScramjetClient) {
@@ -48,7 +48,14 @@ export default function (client: ScramjetClient) {
// fixed where the failure is produced rather than where it surfaces.
// (The SyntaxError for an unparseable URL is already clean: such a
// URL reaches the native exactly as the page wrote it.)
new client.native.window(this).importScripts(...rewritten);
// `Reflect_apply` rather than a spread: the spread would run the
// page-replaceable iteration protocol over the list just rewritten,
// so a page could hand the native other URLs than `rewriteUrl` saw
Reflect_apply(
new client.native.window(this).importScripts,
null,
rewritten
);
}
});
}
@@ -89,7 +89,11 @@ export const BigInt_asUintN = globalThis.BigInt.asUintN;
export const encodeURIComponent = globalThis.encodeURIComponent;
export const Symbol_iterator = globalThis.Symbol.iterator;
// annotated rather than inferred: an alias widens `unique symbol` to plain
// `symbol`, and a computed key of that type builds an index signature instead
// of the well-known `[Symbol.iterator]` member `Iterable<T>` asks for
export const Symbol_iterator: typeof Symbol.iterator =
globalThis.Symbol.iterator;
/**
* `OrdinaryHasInstance`, as `Function.prototype[Symbol.hasInstance]` exposes
* it. Called directly rather than through `instanceof`, which consults the
@@ -208,6 +212,70 @@ export const Array_indexOf = Function_prototype_call.bind(
from?: number
) => number;
/**
* `filter` and `map` rather than the methods, for the same reason everything
* else here is captured: the page can replace `Array.prototype.filter`. They
* are also the array operations that do *not* run the iteration protocol -
* unlike `for...of`, destructuring and spread, which reach
* `%IteratorPrototype%[@@iterator]` and `Array.prototype[@@iterator]`, both of
* which are writable and configurable. See `no-unsafe-iteration`.
*/
export const Array_filter = Function_prototype_call.bind(
globalThis.Array.prototype.filter
) as <T>(array: T[], predicate: (value: T, index: number) => unknown) => T[];
export const Array_map = Function_prototype_call.bind(
globalThis.Array.prototype.map
) as <T, U>(array: T[], transform: (value: T, index: number) => U) => U[];
/**
* A `for...of`-able view of `items` that a page cannot hijack.
*
* `for...of`, array destructuring and spread all run the iteration protocol,
* and every lookup in it lands somewhere a page can write: `@@iterator` on
* `Array.prototype` and on `%IteratorPrototype%`, `next` on the iterator's own
* prototype, and `done`/`value` whenever the result object inherits them.
* Reaching the *value* through `client.native` does not help, because the
* iteration never touches the native.
*
* This hands back a protocol built entirely from own properties of objects
* created right here, so each of those lookups resolves before it reaches
* anything the page can reassign. `for (const x of drain(xs))` is then exactly
* as safe as an indexed loop, and reads like the loop it replaces.
*
* `items` has to be something already safe to index: an array this code built
* - `Object_keys`, `Reflect_ownKeys`, a literal, a native's return value - and
* not a live platform collection, whose `length` is a page-replaceable
* accessor in its own right.
*
* Costs roughly ten times an indexed loop per element. That is nothing at the
* sizes it is used on here, and it is the one reason `SingletonBox.instanceof`
* stays indexed.
*/
export function drain<T>(items: ArrayLike<T>): Iterable<T> {
return {
[Symbol_iterator](): Iterator<T> {
let i = 0;
// one result object, reused. The protocol reads `done` and `value`
// off it before asking for the next step, so a fresh one per step
// buys nothing the engine does not already elide
const step = { done: false, value: undefined as T };
return {
next() {
if (i < items.length) {
step.value = items[i++];
} else {
step.done = true;
step.value = undefined as T;
}
return step as IteratorResult<T>;
},
};
},
};
}
export const JSON_parse = globalThis.JSON.parse;
export const JSON_stringify = globalThis.JSON.stringify;
@@ -244,22 +312,51 @@ export const Math_floor = globalThis.Math.floor;
export const Math_trunc = globalThis.Math.trunc;
export const Math_fround = globalThis.Math.fround;
export const Promise_all = globalThis.Promise.all.bind(globalThis.Promise);
export const Promise_race = globalThis.Promise.race.bind(globalThis.Promise);
const _Promise = globalThis.Promise;
export const Promise_resolve = globalThis.Promise.resolve.bind(
globalThis.Promise
);
export const Promise_reject = globalThis.Promise.reject.bind(
globalThis.Promise
);
export const Promise_allSettled = globalThis.Promise.allSettled.bind(
globalThis.Promise
);
export const Promise_any = globalThis.Promise.any.bind(globalThis.Promise);
export const Promise_then = Function_prototype_call.bind(
globalThis.Promise.prototype.then
);
/**
* `Promise.all`, without the parts of it a page can reach.
*
* Binding the native only fixes which `all` runs. It still walks its argument
* with the iteration protocol, through the page-replaceable
* `Array.prototype[@@iterator]`, and settles each element by calling its
* `then` - `Promise.prototype.then`, also page-writable. Either would let a
* page choose what an awaited batch resolved to. This indexes the array and
* settles each element through the snapshotted `then`.
*
* `items` has to be safe to index, as for `drain`.
*/
export function Promise_all<T>(
items: ArrayLike<T | PromiseLike<T>>
): Promise<Awaited<T>[]> {
return new _Promise((resolve, reject) => {
const results: Awaited<T>[] = [];
let remaining = items.length;
if (remaining === 0) return resolve(results);
for (let i = 0; i < items.length; i++) {
const index = i;
Promise_then(
Promise_resolve(items[i]),
(value: Awaited<T>) => {
results[index] = value;
if (--remaining === 0) resolve(results);
},
reject
);
}
});
}
export const Symbol_for = globalThis.Symbol.for;
declare const WrappedBrand: unique symbol;
@@ -311,9 +311,18 @@ const brandCheckPlugin = {
"CallExpression:exit"(node) {
const frame = frames[frames.length - 1];
if (!frame?.member) return;
const callee = node.callee;
// `Reflect_apply(super.m, this, args)` is `super.m(...args)`
// without the spread, which runs the page-replaceable
// iteration protocol - see `no-unsafe-iteration`
const applied =
node.callee.type === "Identifier" &&
node.callee.name === "Reflect_apply" &&
node.arguments.length >= 2 &&
node.arguments[1].type === "ThisExpression" &&
node.arguments[0].type === "MemberExpression";
const callee = applied ? node.arguments[0] : node.callee;
if (
node.optional ||
(!applied && node.optional) ||
callee.type !== "MemberExpression" ||
callee.optional ||
callee.computed
@@ -0,0 +1,114 @@
/**
* The iteration protocol runs through two intrinsics a page can replace:
*
* %IteratorPrototype%[Symbol.iterator] writable, configurable
* Array.prototype[Symbol.iterator] writable, configurable
*
* `for...of` over a *native iterator* - `headers.entries()`, `map.values()` -
* looks `@@iterator` up on the iterator, which resolves to
* `%IteratorPrototype%`, so replacing it hijacks the loop. Array destructuring
* and spread go through `Array.prototype[@@iterator]` the same way, and that
* holds even for an array scramjet built itself a line earlier.
*
* So any of these, running while page script can have executed, hands the page
* control of what scramjet believes it read. Reaching the object through
* `client.native` does not help: the *iteration* never touches the native.
*
* The sanctioned replacement is `for (const x of drain(xs))`. `drain` hands
* back a protocol made of own properties on objects it creates, so none of
* those lookups reach anything the page can write, and this rule accepts it.
* The alternatives are an indexed `for (let i = 0; ...)` loop, the snapshotted
* `Array_filter` / `Array_map` / `Array_join`, or a callback enumeration the
* native drives itself (`Headers.prototype.forEach` through `client.native`).
*
* Array destructuring and spread are *not* excused by `drain` - they run the
* protocol on the element, not on the collection - so `for (const [k, v] of
* drain(...))` still reports. Destructure an object, or index the entry.
*
* Install-time code is not special-cased. It is safe on pristine intrinsics,
* but `drain` costs nothing that matters at install, and an exemption would
* have to guess at which functions run before page script - `saveNatives`,
* `hook` and `Intercept` are class methods, not the module's default export,
* so the obvious "top-level default export" heuristic would miss most of them.
*/
const PROTOCOL_FORMS = {
ForOfStatement: "forOf",
ArrayPattern: "destructuring",
SpreadElement: "spread",
};
const noUnsafeIterationPlugin = {
rules: {
"no-unsafe-iteration": {
meta: {
type: "problem",
docs: {
description:
"disallow the iteration protocol on values a page can reach",
},
schema: [],
messages: {
forOf:
"`for...of` reads @@iterator off the value, which for a native iterator resolves to the page-replaceable %IteratorPrototype%. Use `for (const x of drain(xs))` from the snapshot, an indexed loop on a hot path, or a native `forEach` through `client.native`.",
destructuring:
"Array destructuring reads the page-replaceable `Array.prototype[Symbol.iterator]`. Index the value instead.",
spread:
"Spread reads @@iterator off the value, which the page can replace. Pass an argument list with `Reflect_apply(fn, this, args)`, or build the array by index.",
},
},
create(context) {
// `drain` has to be the one from the snapshot. A file that never
// imports it cannot be calling it, whatever a local is named
let drainImported = false;
function isDrained(node) {
const right = node.right;
return (
drainImported &&
right?.type === "CallExpression" &&
right.callee.type === "Identifier" &&
right.callee.name === "drain"
);
}
function report(node, messageId) {
context.report({ node, messageId });
}
return {
ImportDeclaration(node) {
if (node.source.value !== "@/shared/snapshot") return;
for (const specifier of node.specifiers) {
if (
specifier.type === "ImportSpecifier" &&
specifier.imported.name === "drain"
) {
drainImported = true;
}
}
},
ForOfStatement(node) {
if (isDrained(node)) return;
report(node, PROTOCOL_FORMS.ForOfStatement);
},
ArrayPattern(node) {
report(node, PROTOCOL_FORMS.ArrayPattern);
},
SpreadElement(node) {
// `{...obj}` is an object spread, which copies own
// enumerable properties and never touches @@iterator
if (node.parent?.type === "ObjectExpression") return;
report(node, PROTOCOL_FORMS.SpreadElement);
},
};
},
},
},
};
export default noUnsafeIterationPlugin;
@@ -27,6 +27,7 @@ for (const body of [
"void super.toString(); return 1;",
"new client.native.Blob(this).size = 1; return 1;",
"void new client.native.Node(this).ELEMENT_NODE; return 1;",
"Reflect_apply(super.slice, other, args); return 1;",
]) {
test(`rejects unchecked path: ${body}`, () => {
const messages = check(body);
@@ -42,6 +43,7 @@ for (const body of [
"void new client.native.Blob(this).size; return 1;",
"if (flag) return super.size; super.slice(); return 1;",
"try { return super.size; } catch { throw Error(); }",
"Reflect_apply(super.slice, this, args); return 1;",
]) {
test(`accepts native check: ${body}`, () =>
assert.deepEqual(check(body), []));
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Linter } from "eslint";
import plugin from "../eslint/no-unsafe-iteration-plugin.mjs";
const linter = new Linter();
function check(code) {
return linter.verify(code, [
{
plugins: { local: plugin },
rules: { "local/no-unsafe-iteration": "error" },
languageOptions: { ecmaVersion: 2022, sourceType: "module" },
},
]);
}
const DRAIN = 'import { drain } from "@/shared/snapshot";\n';
for (const code of [
"for (const x of xs) f(x);",
"const [a, b] = pair;",
"f(...args);",
"const copy = [...xs];",
// a local called drain is not the snapshot's
"const drain = (x) => x; for (const x of drain(xs)) f(x);",
// drain covers the collection, not destructuring of each element
DRAIN + "for (const [k, v] of drain(entries)) f(k, v);",
]) {
test(`rejects: ${code}`, () => assert.ok(check(code).length > 0));
}
for (const code of [
DRAIN + "for (const x of drain(xs)) f(x);",
"for (let i = 0; i < xs.length; i++) f(xs[i]);",
"const copy = { ...obj };",
"const { a, b } = obj;",
]) {
test(`accepts: ${code}`, () => assert.deepEqual(check(code), []));
}