mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(codemode): bound replacement strings, argument counts, built-in recursion depth, thenable chains, and rejection diagnostics
This commit is contained in:
@@ -34,10 +34,20 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
shadowable by program declarations like other globals.
|
||||
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
|
||||
- [x] The timeout fires between interpreter steps, so one built-in is bounded in what it may build: strings up to
|
||||
2^24 characters (`repeat`, `pad*`, `concat`, `join`, `+`, template literals, `JSON.stringify`), arrays up to
|
||||
10,000,000 elements (`Array(n)`, `length =`, `Array.from`, `split`, `matchAll`, `concat`, `flat`; below the JS
|
||||
maximum of 2^32 - 1), and 10,000 pending promises at once. Exceeding one throws a `RangeError`. A single regular
|
||||
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
|
||||
2^24 characters (`repeat`, `pad*`, `concat`, `join`, `+`, template literals, `JSON.stringify`, `replace` and
|
||||
`replaceAll` with a string replacement (checked before the host builds the result), `encodeURI*`, `btoa`,
|
||||
`Uint8Array` `toString`/`toBase64`/`toHex`, `URLSearchParams.toString`), arrays up to 10,000,000 elements
|
||||
(`Array(n)`, `length =`, `Array.from`, spread, rest, `split`, `matchAll`, `concat`, `flat`; below the JS maximum
|
||||
of 2^32 - 1), 250,000 arguments to one call (spread arguments, `apply`, bound arguments), and 10,000 pending
|
||||
promises at once. Exceeding one throws a `RangeError`. A replacement whose quick bound is too big is measured
|
||||
first: the check is exact for a global replacement without `$`, and otherwise charges each `$` token the longest
|
||||
group of its match (a whole subject for `` $` `` and `$'`), so a result near the cap may still be rejected.
|
||||
`encodeURI*` and `Uint8Array.toString` are checked after the host builds the string (at most nine times a capped
|
||||
input). Unhandled rejections from un-awaited promises are reported individually up to 100, each message cut at
|
||||
4,096 characters, with one summary warning counting the rest that were never handled.
|
||||
- [ ] A single regular expression match can still run long on a pathological pattern (`/a*a*a*a*b/` on a 1 KB
|
||||
subject): the host regex engine has no interrupt hook and no subject-length cap helps beyond quadratic patterns,
|
||||
so this needs a step-counted regex engine of our own.
|
||||
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
|
||||
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
|
||||
parse time. Without it, `yield` is an ordinary binding.
|
||||
@@ -124,8 +134,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
|
||||
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
|
||||
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
|
||||
call that invoked the built-in, and a resumed `await` starts from depth 0 as in JS, so long async chains such
|
||||
as recursive pagination are unaffected.
|
||||
call that invoked the built-in, and built-ins invoking one another count toward the same limit, so a cycle
|
||||
through built-ins alone (`String(a)` on a 100,000-deep nested array runs `toString` → `join` → `toString`)
|
||||
bottoms out too. A resumed `await` starts from depth 0 as in JS, so long async chains such as recursive pagination are
|
||||
unaffected. A thenable that keeps resolving with another thenable loops in constant memory until the timeout, as
|
||||
in JS.
|
||||
- [x] Expression and block function bodies.
|
||||
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
|
||||
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
|
||||
|
||||
@@ -21,7 +21,7 @@ import { errorGlobal } from "./errors.js"
|
||||
import { errorTypes } from "./intrinsics.js"
|
||||
import { constants, constructor, methods, native, receiver } from "./native.js"
|
||||
import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js"
|
||||
import { checkArrayLength } from "./limits.js"
|
||||
import { checkArgumentCount } from "./limits.js"
|
||||
import { generatorGlobals } from "./generators.js"
|
||||
import { promiseGlobal } from "./promises.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
@@ -45,7 +45,10 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return native<R>(ctx.builtins, {
|
||||
name: `bound ${coerceToString(get(fn, "name"))}`,
|
||||
length: Math.max(0, fn.length - bound.length),
|
||||
call: (_, rest) => ctx.call(fn, args[0], [...bound, ...rest]),
|
||||
call: (_, rest) => {
|
||||
checkArgumentCount(bound.length + rest.length)
|
||||
return ctx.call(fn, args[0], [...bound, ...rest])
|
||||
},
|
||||
})
|
||||
},
|
||||
],
|
||||
@@ -61,10 +64,13 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
// CreateListFromArrayLike: `apply` reads `length` and the indexed properties of any object.
|
||||
const listFromArrayLike = (value: Value): Array<Value> => {
|
||||
if (value === undefined || value === null) return []
|
||||
if (value instanceof Arr) return [...value.items]
|
||||
if (value instanceof Arr) {
|
||||
checkArgumentCount(value.items.length)
|
||||
return [...value.items]
|
||||
}
|
||||
if (!(value instanceof Obj)) throw typeError("Function.prototype.apply expects an array-like argument list.")
|
||||
const length = Math.max(0, coerceToInteger(get(value, "length")))
|
||||
checkArrayLength(length)
|
||||
checkArgumentCount(length)
|
||||
return Array.from({ length }, (_, index) => get(value, String(index)))
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
typeError,
|
||||
unsupportedSyntax,
|
||||
} from "./model.js"
|
||||
import { checkStringLength } from "./limits.js"
|
||||
import { checkArgumentCount, checkArrayLength, checkStringLength } from "./limits.js"
|
||||
import { locate, materialize } from "./errors.js"
|
||||
import { type Builtins, primitivePrototype } from "./intrinsics.js"
|
||||
import { globals } from "./globals.js"
|
||||
@@ -319,7 +319,7 @@ export class Interpreter<R> {
|
||||
readonly logs: Array<string>
|
||||
/** Template objects by site: a tag sees the same `strings` array every time its literal is evaluated, as in JS. */
|
||||
readonly templates = new WeakMap<TaggedTemplateExpression, Arr>()
|
||||
private readonly root: Frame<R>
|
||||
readonly root: Frame<R>
|
||||
|
||||
constructor(options: {
|
||||
readonly tools: ToolRuntime<R>
|
||||
@@ -1281,6 +1281,7 @@ class Frame<R> {
|
||||
const next = yield* cursor.next
|
||||
done = next.done
|
||||
if (!done) rest.push(next.value)
|
||||
checkArrayLength(rest.length)
|
||||
}
|
||||
yield* consume(element.argument, new Arr(self.ctx.builtins.Array, rest), element)
|
||||
return
|
||||
@@ -1728,15 +1729,19 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it. A built-in
|
||||
// reached through `ctx.call` runs on the root frame, so the deeper of the frame and the enclosing site counts.
|
||||
// reached through `ctx.call` runs on the root frame, so the deeper of the frame and the enclosing site counts. There
|
||||
// it was invoked by another built-in, and those hops are counted too, so a cycle through built-ins alone
|
||||
// (toString → join → toString) bottoms out without changing the depth of program calls.
|
||||
private native(body: () => Effect.Effect<Value, unknown, R>, node?: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
return Effect.flatMap(CallSite, (site) =>
|
||||
Effect.provideService(
|
||||
return Effect.flatMap(CallSite, (site) => {
|
||||
const natives = this === this.ctx.root ? site.natives + 1 : 0
|
||||
if (natives > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node)
|
||||
return Effect.provideService(
|
||||
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
|
||||
CallSite,
|
||||
{ node, depth: Math.max(this.depth, site.depth) },
|
||||
),
|
||||
)
|
||||
{ node, depth: Math.max(this.depth, site.depth), natives },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateCallArguments(
|
||||
@@ -1754,6 +1759,7 @@ class Frame<R> {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) break
|
||||
args.push(step.value)
|
||||
checkArgumentCount(args.length)
|
||||
}
|
||||
} else {
|
||||
args.push(yield* self.evaluateExpression(argNode))
|
||||
@@ -2113,6 +2119,7 @@ class Frame<R> {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) break
|
||||
values.push(step.value)
|
||||
checkArrayLength(values.length)
|
||||
}
|
||||
} else {
|
||||
values.push(yield* self.evaluateExpression(element))
|
||||
|
||||
@@ -7,6 +7,8 @@ import { rangeError } from "./model.js"
|
||||
export const MAX_STRING_LENGTH = 1 << 24
|
||||
/** Longest array a built-in may create or grow to. */
|
||||
export const MAX_ARRAY_LENGTH = 10_000_000
|
||||
/** Most arguments one call may receive, including a bound function's bound arguments. */
|
||||
export const MAX_ARGUMENTS = 250_000
|
||||
/** Most promises that may be pending at once. */
|
||||
export const MAX_PENDING_PROMISES = 10_000
|
||||
/** Deepest nesting a value may have when it crosses to or from the host. */
|
||||
@@ -16,6 +18,16 @@ export const checkStringLength = (length: number): void => {
|
||||
if (length > MAX_STRING_LENGTH) throw rangeError("Invalid string length")
|
||||
}
|
||||
|
||||
/** A string the host already built, after checking its length; for outputs at most nine times a capped input. */
|
||||
export const boundedString = (value: string): string => {
|
||||
checkStringLength(value.length)
|
||||
return value
|
||||
}
|
||||
|
||||
export const checkArgumentCount = (count: number): void => {
|
||||
if (count > MAX_ARGUMENTS) throw rangeError(`Too many arguments: a call may pass at most ${MAX_ARGUMENTS}.`)
|
||||
}
|
||||
|
||||
export const checkArrayLength = (length: number): void => {
|
||||
if (length > MAX_ARRAY_LENGTH) throw rangeError("Invalid array length")
|
||||
}
|
||||
|
||||
@@ -7,10 +7,15 @@ import type { ErrorObj, Value } from "./objects.js"
|
||||
/** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */
|
||||
export type AstNode = Node
|
||||
|
||||
/** The program call a built-in is running under: where to locate failures born inside it, and how deep the stack is there. */
|
||||
export const CallSite = Context.Reference<{ readonly node?: AstNode; readonly depth: number }>("codemode/CallSite", {
|
||||
defaultValue: () => ({ depth: 0 }),
|
||||
})
|
||||
/**
|
||||
* The program call a built-in is running under: where to locate failures born inside it, how deep the stack is there,
|
||||
* and how many built-ins have invoked one another since program code last called one.
|
||||
*/
|
||||
export const CallSite = Context.Reference<{
|
||||
readonly node?: AstNode
|
||||
readonly depth: number
|
||||
readonly natives: number
|
||||
}>("codemode/CallSite", { defaultValue: () => ({ depth: 0, natives: 0 }) })
|
||||
|
||||
export type Binding = {
|
||||
mutable: boolean
|
||||
|
||||
@@ -16,12 +16,20 @@ const capability = <R>(ctx: Interpreter<R>, name: string, settle: (value: Value)
|
||||
return undefined
|
||||
})
|
||||
|
||||
// Unhandled rejections past the first hundred are only counted, and each reported message is cut, so a rejection loop
|
||||
// cannot grow the report without bound.
|
||||
const MAX_REJECTION_DIAGNOSTICS = 100
|
||||
const MAX_DIAGNOSTIC_LENGTH = 4096
|
||||
|
||||
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
||||
export class Pending<R> {
|
||||
private readonly active = new Set<PromiseObj>()
|
||||
private readonly ids = new WeakMap<PromiseObj, number>()
|
||||
private readonly observed = new WeakSet<PromiseObj>()
|
||||
private readonly failures = new Map<number, Diagnostic>()
|
||||
// An unreachable dropped promise can never be handled later, so a weak set is enough to keep the count exact.
|
||||
private readonly dropped = new WeakSet<PromiseObj>()
|
||||
private droppedCount = 0
|
||||
private nextID = 0
|
||||
|
||||
constructor(
|
||||
@@ -60,11 +68,17 @@ export class Pending<R> {
|
||||
this.ids.delete(promise)
|
||||
return
|
||||
}
|
||||
if (this.failures.size >= MAX_REJECTION_DIAGNOSTICS) {
|
||||
this.dropped.add(promise)
|
||||
this.droppedCount += 1
|
||||
return
|
||||
}
|
||||
const failure = normalizeError(Cause.squash(exit.cause))
|
||||
this.failures.set(id, {
|
||||
...failure,
|
||||
message: `Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
||||
})
|
||||
const reason = failure.message.slice(0, MAX_DIAGNOSTIC_LENGTH)
|
||||
// A JSC slice shares the memory of the string it cuts; slicing the short joined string copies it, so the long
|
||||
// original message is freed.
|
||||
const message = `Unhandled rejection from an un-awaited promise: ${reason}`.slice(0, MAX_DIAGNOSTIC_LENGTH)
|
||||
this.failures.set(id, { ...failure, message })
|
||||
})
|
||||
return promise
|
||||
})
|
||||
@@ -74,6 +88,7 @@ export class Pending<R> {
|
||||
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
|
||||
markObserved(promise: PromiseObj): void {
|
||||
this.observed.add(promise)
|
||||
if (this.dropped.delete(promise)) this.droppedCount -= 1
|
||||
const id = this.ids.get(promise)
|
||||
this.ids.delete(promise)
|
||||
if (id !== undefined) this.failures.delete(id)
|
||||
@@ -88,7 +103,15 @@ export class Pending<R> {
|
||||
}
|
||||
|
||||
diagnostics(): Array<Diagnostic> {
|
||||
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
|
||||
const retained = [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
|
||||
if (this.droppedCount === 0) return retained
|
||||
return [
|
||||
...retained,
|
||||
{
|
||||
kind: "ExecutionFailure",
|
||||
message: `Unhandled rejections from un-awaited promises not reported individually: ${this.droppedCount}.`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Re-check because a straggler can create promises before its interruption lands.
|
||||
@@ -116,6 +139,8 @@ export const resolvePromiseValue = <R>(
|
||||
const then = get(value, "then")
|
||||
if (typeofValue(then) !== "function") return Effect.succeed(value)
|
||||
|
||||
// The next thenable resolves through a tail flatMap, not a nested yield*, so a chain that never ends runs in
|
||||
// constant memory, as in JS.
|
||||
return Effect.gen(function* () {
|
||||
// Promise resolution invokes a thenable's method in a later job.
|
||||
yield* Effect.yieldNow
|
||||
@@ -127,8 +152,8 @@ export const resolvePromiseValue = <R>(
|
||||
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
|
||||
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
|
||||
}
|
||||
return yield* resolvePromiseValue(ctx, yield* Deferred.await(deferred), own)
|
||||
})
|
||||
return yield* Deferred.await(deferred)
|
||||
}).pipe(Effect.flatMap((next) => resolvePromiseValue(ctx, next, own)))
|
||||
}
|
||||
|
||||
export const resolvePromise = <R>(ctx: Interpreter<R>, value: Value): Effect.Effect<PromiseObj, never, R> => {
|
||||
|
||||
@@ -64,6 +64,7 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
|
||||
? step.value
|
||||
: yield* preserveConsumerError(cursor.close, apply([step.value, index], args[2])),
|
||||
)
|
||||
checkArrayLength(values.length)
|
||||
index += 1
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
|
||||
import { boundedString, checkArrayLength, checkStringLength } from "../interpreter/limits.js"
|
||||
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { IteratorSymbol, rangeError, syntaxError, typeError } from "../interpreter/model.js"
|
||||
import {
|
||||
@@ -171,9 +171,17 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return joined
|
||||
},
|
||||
],
|
||||
["toString", 0, (thisValue) => self(thisValue, "toString").bytes.join(",")],
|
||||
["toString", 0, (thisValue) => boundedString(self(thisValue, "toString").bytes.join(","))],
|
||||
["toBase64", 0, (thisValue) => self(thisValue, "toBase64").bytes.toBase64()],
|
||||
["toHex", 0, (thisValue) => self(thisValue, "toHex").bytes.toHex()],
|
||||
[
|
||||
"toHex",
|
||||
0,
|
||||
(thisValue) => {
|
||||
const bytes = self(thisValue, "toHex").bytes
|
||||
checkStringLength(bytes.length * 2)
|
||||
return bytes.toHex()
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").bytes.keys())],
|
||||
["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").bytes.values())],
|
||||
[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, fn, type Impl, type Method, methods } from "../interpreter/native.js"
|
||||
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
|
||||
import { checkArrayLength, checkStringLength, MAX_STRING_LENGTH } from "../interpreter/limits.js"
|
||||
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
|
||||
import {
|
||||
define,
|
||||
@@ -36,6 +36,45 @@ const requireDataArgument = (name: string, index: number, arg: Value): Value =>
|
||||
return arg
|
||||
}
|
||||
|
||||
// The host builds a replaced string in one synchronous step, so bound its length first: the unmatched text plus, per
|
||||
// match, the replacement, a whole subject per `` $` `` or `$'`, and the match's longest group per `$` token. Groups lie
|
||||
// inside their match, so they sum to at most the subject, unless a positive lookaround captures past it. Matches are
|
||||
// only measured when the bound for "every position matches" is too big; the measured bound is exact without `$`.
|
||||
const checkReplacementLength = (subject: string, search: RegExp | string, replacement: string, all: boolean): void => {
|
||||
const tokens = (token: string) => countOccurrences(replacement, token)
|
||||
const perMatch = replacement.length + (tokens("$`") + tokens("$'")) * subject.length
|
||||
const bound = (matches: { count: number; matched: number; reach: number }) =>
|
||||
subject.length - matches.matched + matches.count * perMatch + tokens("$") * matches.reach
|
||||
const lookaround = typeof search !== "string" && /\(\?<?=/.test(search.source)
|
||||
const reach = lookaround ? (subject.length + 1) * subject.length : subject.length
|
||||
if (bound({ count: subject.length + 1, matched: 0, reach }) <= MAX_STRING_LENGTH) return
|
||||
if (!all) return checkStringLength(bound({ count: 1, matched: 0, reach: subject.length }))
|
||||
if (typeof search === "string") {
|
||||
const count = countOccurrences(subject, search)
|
||||
return checkStringLength(bound({ count, matched: count * search.length, reach: count * search.length }))
|
||||
}
|
||||
// matchAll steps past an empty match by code point under `u` and `v`; the copy starts at 0 whatever the program's
|
||||
// `lastIndex` is, as a global `replace` does.
|
||||
const matches = subject.matchAll(new RegExp(search)).reduce(
|
||||
(total, match) => ({
|
||||
count: total.count + 1,
|
||||
matched: total.matched + match[0].length,
|
||||
reach: total.reach + match.reduce((longest, group) => Math.max(longest, group?.length ?? 0), 0),
|
||||
}),
|
||||
{ count: 0, matched: 0, reach: 0 },
|
||||
)
|
||||
checkStringLength(bound(matches))
|
||||
}
|
||||
|
||||
const countOccurrences = (subject: string, needle: string): number => {
|
||||
if (needle === "") return subject.length + 1
|
||||
let count = 0
|
||||
for (let index = subject.indexOf(needle); index !== -1; index = subject.indexOf(needle, index + needle.length)) {
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
const replaceAllNeedsGlobal = (pattern: RegExp) => {
|
||||
if (!pattern.global) {
|
||||
throw typeError(
|
||||
@@ -208,10 +247,13 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const regex = pattern.regex
|
||||
const text = str(name, primitives, 1)
|
||||
if (name === "replaceAll") replaceAllNeedsGlobal(regex)
|
||||
checkReplacementLength(value, regex, text, regex.global)
|
||||
return name === "replace" ? value.replace(regex, text) : value.replaceAll(regex, text)
|
||||
}
|
||||
if (name === "replace") return value.replace(str(name, primitives, 0), str(name, primitives, 1))
|
||||
return value.replaceAll(str(name, primitives, 0), str(name, primitives, 1))
|
||||
const needle = str(name, primitives, 0)
|
||||
const text = str(name, primitives, 1)
|
||||
checkReplacementLength(value, needle, text, name === "replaceAll")
|
||||
return name === "replace" ? value.replace(needle, text) : value.replaceAll(needle, text)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { boundedString, checkStringLength } from "../interpreter/limits.js"
|
||||
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { IteratorSymbol, PendingThrow, typeError, uriError } from "../interpreter/model.js"
|
||||
import {
|
||||
@@ -46,9 +47,10 @@ export const uriGlobal = <R>(ctx: Interpreter<R>, name: UriFunction) =>
|
||||
fn<R>(ctx.builtins, name, 1, (_, args) => {
|
||||
const value = coerceToString(args[0])
|
||||
try {
|
||||
return uriFunctions[name](value)
|
||||
return boundedString(uriFunctions[name](value))
|
||||
} catch (error) {
|
||||
throw uriError(`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`)
|
||||
if (!(error instanceof URIError)) throw error
|
||||
throw uriError(`${name} received malformed URI data: ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -272,7 +274,17 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").params.keys())],
|
||||
["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").params.values())],
|
||||
["entries", 0, (thisValue) => hostIterator(builtins, self(thisValue, "entries").iterator(builtins))],
|
||||
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
|
||||
[
|
||||
"toString",
|
||||
0,
|
||||
(thisValue) => {
|
||||
const params = self(thisValue, "toString").params
|
||||
// Every character serializes to at least one, plus `=` per pair and `&` between pairs, so this lower bound
|
||||
// rejects before the host builds anything.
|
||||
checkStringLength([...params].reduce((length, [key, value]) => length + key.length + value.length + 2, -1))
|
||||
return boundedString(params.toString())
|
||||
},
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fn, methods } from "../interpreter/native.js"
|
||||
import { checkStringLength } from "../interpreter/limits.js"
|
||||
import { typeError } from "../interpreter/model.js"
|
||||
import {
|
||||
define,
|
||||
@@ -32,6 +33,7 @@ export const base64Global = <R>(ctx: Interpreter<R>, name: "atob" | "btoa") =>
|
||||
fn<R>(ctx.builtins, name, 1, (_, args) => {
|
||||
if (args.length === 0) throw typeError(`${name} requires 1 argument (a string)`)
|
||||
const input = coerceToString(args[0])
|
||||
if (name === "btoa") checkStringLength(Math.ceil(input.length / 3) * 4)
|
||||
try {
|
||||
return name === "atob" ? atob(input) : btoa(input)
|
||||
} catch {
|
||||
|
||||
@@ -1560,7 +1560,7 @@ describe("this, arguments, and Function.prototype.call/apply/bind", () => {
|
||||
"Function.prototype.call called on incompatible receiver",
|
||||
)
|
||||
expect((await error(`(() => 1).apply(null, 5)`)).message).toContain("expects an array-like argument list")
|
||||
expect((await error(`(() => 1).apply(null, { length: 1e9 })`)).message).toContain("Invalid array length")
|
||||
expect((await error(`(() => 1).apply(null, { length: 1e9 })`)).message).toContain("Too many arguments")
|
||||
expect(
|
||||
await value(
|
||||
`function f() { return arguments.length } return [f.apply(null, { length: -5 }), f.apply(null, { length: "2" })]`,
|
||||
@@ -2060,3 +2060,157 @@ describe("small language leftovers", () => {
|
||||
expect((await error(`function* g() {} Object.getPrototypeOf(g)()`)).message).toContain("not a function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("confinement caps", () => {
|
||||
test("string replacement results are bounded before the host builds them", async () => {
|
||||
expect((await error(`"x".repeat(2 ** 16).replaceAll("x", "x".repeat(2 ** 9))`)).message).toContain(
|
||||
"Invalid string length",
|
||||
)
|
||||
expect((await error(`"x".repeat(2 ** 20).replace(/x/g, "yyyyyyyyyyyyyyyyyyyyyyyyy")`)).message).toContain(
|
||||
"Invalid string length",
|
||||
)
|
||||
// Every prefix token can repeat the subject once per match, and so can a capture inside a lookahead.
|
||||
expect((await error('"x".repeat(2600).replace(/x/g, "$`".repeat(100))')).message).toContain("Invalid string length")
|
||||
expect((await error('"x".repeat(20000).replace(/(?=(x*))/g, "$1")')).message).toContain("Invalid string length")
|
||||
expect(
|
||||
(await error(`const p = new URLSearchParams(); const s = "x".repeat(2 ** 24); p.append(s, s); p.toString()`))
|
||||
.message,
|
||||
).toContain("Invalid string length")
|
||||
// Counting empty matches must step over surrogate pairs under the u flag.
|
||||
expect(
|
||||
await value(
|
||||
`return ["😀".repeat(10).replace(/(?:)/gu, "x".repeat(2 ** 20)).length, "1234567".replace(/(\\d)(?=(\\d{3})+$)/g, "$1,")]`,
|
||||
),
|
||||
).toEqual([11534356, "1,234,567"])
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
"x".repeat(2 ** 23).replaceAll("x", "y").length,
|
||||
"ab".repeat(2 ** 21).replace(/a/g, "$&").length,
|
||||
"abc".replaceAll("", "-"),
|
||||
"aaa".replace(/a/g, (m) => m + m),
|
||||
"hello world ".repeat(2000).replace(/(\\w+)(?=\\s)/g, "[$1]").length,
|
||||
"x".repeat(100).replaceAll("x", "y".repeat(167772)).length,
|
||||
"a ".repeat(3e6).replace(/ +/g, " ").length,
|
||||
]
|
||||
`),
|
||||
).toEqual([8388608, 4194304, "-a-b-c-", "aaaaaa", 32000, 16777200, 6000000])
|
||||
expect((await error(`encodeURIComponent("\\u{1F600}".repeat(2 ** 22))`)).message).toContain("Invalid string length")
|
||||
expect((await error(`btoa("x".repeat(2 ** 24))`)).message).toContain("Invalid string length")
|
||||
expect((await error(`new Uint8Array(9e6).toHex()`)).message).toContain("Invalid string length")
|
||||
expect((await error(`new Uint8Array(9e6).toString()`)).message).toContain("Invalid string length")
|
||||
expect(
|
||||
(await error(`const p = new URLSearchParams(); p.append("\\u20ac".repeat(2 ** 21), ""); p.toString()`)).message,
|
||||
).toContain("Invalid string length")
|
||||
expect((await error(`decodeURIComponent("%")`)).message).toContain("malformed URI")
|
||||
})
|
||||
|
||||
test("recursion through built-ins alone hits the call depth limit", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let a = []
|
||||
for (let i = 0; i < 100000; i++) a = [a]
|
||||
let shallow = []
|
||||
for (let i = 0; i < 3000; i++) shallow = [shallow]
|
||||
const names = []
|
||||
try { String(a) } catch (e) { names.push(e.name) }
|
||||
try { \`\${a}\` } catch (e) { names.push(e.name) }
|
||||
const b = [1]
|
||||
b.join = b.toString
|
||||
try { String(b) } catch (e) { names.push(e.name) }
|
||||
return [names, String(shallow).length, [[[1]]].map((x) => String(x))]
|
||||
`),
|
||||
).toEqual([["RangeError", "RangeError", "RangeError"], 0, ["1"]])
|
||||
// A resumed await starts from depth 0 even when each step recurses through a bound callback of a built-in.
|
||||
expect(
|
||||
await value(`
|
||||
let n = 0
|
||||
let failure = null
|
||||
async function step() {
|
||||
await null
|
||||
n++
|
||||
if (n < 12000) [0].forEach((() => { step().catch((e) => { failure = e.name }) }).bind(null))
|
||||
}
|
||||
await step()
|
||||
while (n < 12000 && failure === null) await null
|
||||
return [n, failure]
|
||||
`),
|
||||
).toEqual([12000, null])
|
||||
})
|
||||
|
||||
test("argument and spread counts are capped", async () => {
|
||||
expect((await error(`Math.max(...Array(300000).fill(1))`)).message).toContain("Too many arguments")
|
||||
expect((await error(`Math.max.apply(null, { length: 1e9 })`)).message).toContain("Too many arguments")
|
||||
expect(
|
||||
(await error(`function f() { return arguments.length } const a = Array(2e5).fill(0); f(...a, ...a)`)).message,
|
||||
).toContain("Too many arguments")
|
||||
expect((await error(`function f() {} f.apply(null, Array(300000).fill(0))`)).message).toContain(
|
||||
"Too many arguments",
|
||||
)
|
||||
expect(
|
||||
(
|
||||
await error(
|
||||
`const a = Array(2e5).fill(0); const f = ((...xs) => xs.length).bind(null, ...a).bind(null, ...a); f()`,
|
||||
)
|
||||
).message,
|
||||
).toContain("Too many arguments")
|
||||
expect((await error(`const a = Array(6e6).fill(0); [...a, ...a]`)).message).toContain("Invalid array length")
|
||||
expect(await value(`return Math.max(...Array(200000).fill(7))`)).toBe(7)
|
||||
})
|
||||
|
||||
test("a thenable chain resolves iteratively and a self-resolving promise is a chaining cycle", async () => {
|
||||
expect(
|
||||
await value(
|
||||
`const mk = (n) => n === 0 ? "done" : { then(res) { res(mk(n - 1)) } }; return await Promise.resolve(mk(5000))`,
|
||||
),
|
||||
).toBe("done")
|
||||
expect(
|
||||
(await error(`let p; p = new Promise((r) => Promise.resolve().then(() => r(p))); await p`)).message,
|
||||
).toContain("Chaining cycle")
|
||||
// The cycle check still applies after a thenable step hands back the promise being resolved.
|
||||
expect(
|
||||
(await error(`let p; p = new Promise((r) => Promise.resolve().then(() => r({ then(r2) { r2(p) } }))); await p`))
|
||||
.message,
|
||||
).toContain("Chaining cycle")
|
||||
})
|
||||
|
||||
test("a thenable that resolves with itself runs in constant memory until the timeout", async () => {
|
||||
Bun.gc(true)
|
||||
const before = process.memoryUsage().heapUsed
|
||||
let peak = before
|
||||
const sample = setInterval(() => (peak = Math.max(peak, process.memoryUsage().heapUsed)), 50)
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `const t = { then(res) { res(t) } }; await Promise.resolve(t)`,
|
||||
tools: {},
|
||||
limits: { timeoutMs: 1000 },
|
||||
}),
|
||||
)
|
||||
clearInterval(sample)
|
||||
expect(result.ok ? undefined : result.error.kind).toBe("TimeoutExceeded")
|
||||
expect(peak - before).toBeLessThan(50_000_000)
|
||||
})
|
||||
|
||||
test("unhandled rejection diagnostics are capped with a summary", async () => {
|
||||
const result = await run(`for (let i = 0; i < 500; i++) Promise.reject(new Error("x".repeat(10000))); return 1`)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
expect(result.warnings?.length).toBe(101)
|
||||
expect(result.warnings?.[0]?.message.length).toBeLessThanOrEqual(4096)
|
||||
expect(result.warnings?.at(-1)?.message).toContain("not reported individually: 400")
|
||||
const handled = await run(
|
||||
`const ps = []; for (let i = 0; i < 200; i++) ps.push(Promise.reject(new Error("x"))); await Promise.allSettled(ps); return 1`,
|
||||
)
|
||||
expect(handled.ok && handled.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
test("cut rejection messages do not keep the full message alive", async () => {
|
||||
Bun.gc(true)
|
||||
const before = process.memoryUsage().heapUsed
|
||||
const result = await run(
|
||||
`for (let i = 0; i < 50; i++) Promise.reject(new Error(i + "x".repeat(2 ** 22))); return 1`,
|
||||
)
|
||||
Bun.gc(true)
|
||||
expect(result.ok && result.warnings?.length).toBe(50)
|
||||
expect(process.memoryUsage().heapUsed - before).toBeLessThan(50_000_000)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user