Fix the X509 recursive-type crash and lower the refused array species forms (#2467)

This commit is contained in:
Chris Tate
2026-10-28 09:45:10 -05:00
committed by GitHub
15 changed files with 225 additions and 44 deletions
@@ -647,26 +647,27 @@ export function lowerWithArraySpecies(
// instance is created (with that length for map, 0 otherwise), then
// the method runs to that length and its elements move into the
// instance.
const bounded =
result.kind === "call" &&
result.args[0]?.kind === "varRef" &&
result.args[0].localId === local.id
? boundedArrayHofHelper(lowerer, result.callee)
: null;
if (bounded === null || result.kind !== "call")
const stmts: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: receiver, loc }];
let bound: IrLocal | undefined;
const rebound = rebuildSpeciesCall(result, local.id, (inner) => {
const args: IrExpr[] = [receiverRef];
for (const arg of inner.args.slice(1)) {
const held = lowerer.declareHiddenLocal("%speciesArgument", arg.type);
stmts.push({ kind: "varDecl", localId: held.id, init: arg, loc });
args.push({ kind: "varRef", localId: held.id, type: arg.type, loc });
}
const length = lowerer.declareHiddenLocal("%speciesLength", F64);
bound = length;
args.push({ kind: "varRef", localId: length.id, type: F64, loc });
const helper = boundedArrayHofHelper(lowerer, inner.callee);
return helper === null ? null : { ...inner, callee: helper, args };
});
if (rebound === null || bound === undefined)
lowerer.unsupported(
"SC1090",
call,
`'${access.name.text}' in this form when the program declares a class that extends Array`,
);
const stmts: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: receiver, loc }];
const args: IrExpr[] = [receiverRef];
for (const arg of result.args.slice(1)) {
const held = lowerer.declareHiddenLocal("%speciesArgument", arg.type);
stmts.push({ kind: "varDecl", localId: held.id, init: arg, loc });
args.push({ kind: "varRef", localId: held.id, type: arg.type, loc });
}
const bound = lowerer.declareHiddenLocal("%speciesLength", F64);
stmts.push({
kind: "varDecl",
localId: bound.id,
@@ -681,7 +682,6 @@ export function lowerWithArraySpecies(
loc,
});
const boundRef: IrExpr = { kind: "varRef", localId: bound.id, type: F64, loc };
args.push(boundRef);
const instance = lowerer.declareHiddenLocal("%speciesInstance", receiver.type);
const length: IrExpr = mode.resultLength
? boundRef
@@ -706,7 +706,7 @@ export function lowerWithArraySpecies(
fn: "array.speciesFinish",
args: [
{ kind: "varRef", localId: instance.id, type: receiver.type, loc },
{ ...result, callee: bounded, args },
rebound,
{ kind: "boolLit", value: mode.setLength, type: BOOL, loc },
],
type: result.type,
@@ -736,6 +736,31 @@ export function lowerWithArraySpecies(
};
}
/** `e` with the `%arr.<method>` helper call over `receiverLocal` rebuilt by
* `rebuild`, or null when `e` holds no such call. The helper call is the
* one taking the receiver as its first argument; a filter whose callback
* narrows converts the filtered array after the helper ran, so the call
* can sit behind a wrapper (the checked-narrowing call), which this
* rebuild leaves in place. */
function rebuildSpeciesCall(
e: IrExpr,
receiverLocal: string,
rebuild: (inner: IrExpr & { kind: "call" }) => IrExpr | null,
): IrExpr | null {
if (e.kind !== "call") return null;
const first = e.args[0];
if (first !== undefined && first.kind === "varRef" && first.localId === receiverLocal)
return rebuild(e);
for (let i = 0; i < e.args.length; i++) {
const replaced = rebuildSpeciesCall(e.args[i]!, receiverLocal, rebuild);
if (replaced === null) continue;
const args = e.args.slice();
args[i] = replaced;
return { ...e, args };
}
return null;
}
/** `C.of(...items)` and `C.from(iterable)` on a class declared `extends
* Array` that declares neither: Array.of / Array.from's elements, in an
* instance the class constructs (`new C(items.length)` for of, `new C()`
@@ -62,6 +62,22 @@ export function refuseX509DynValues(lowerer: Lowerer, functions: readonly IrFunc
)
return;
const seen = new Map<string, boolean>();
// A type is recursive when it reaches itself without naming a shape or a
// union: `type Rec = string | Rec[]` passes through the array, set, map
// or promise case below. Those kinds have no id to memoize by, so a type
// met again while it is still being visited answers false: what a cycle
// adds to `holds` (a certificate exists somewhere in the type) is
// reachability, and nothing outside the cycle makes it reachable.
const visiting = new Set<IrType>();
const descend = (t: IrType, visit: () => boolean): boolean => {
if (visiting.has(t)) return false;
visiting.add(t);
try {
return visit();
} finally {
visiting.delete(t);
}
};
const holds = (t: IrType): boolean => {
switch (t.kind) {
case "record": {
@@ -79,13 +95,24 @@ export function refuseX509DynValues(lowerer: Lowerer, functions: readonly IrFunc
}
case "array":
case "set":
return holds(t.elem);
return descend(t, () => holds(t.elem));
case "map":
return holds(t.key) || holds(t.value);
return descend(t, () => holds(t.key) || holds(t.value));
case "promise":
return holds(t.inner);
case "union":
return lowerer.unions.get(t.unionId)?.arms.some(holds) === true;
return descend(t, () => holds(t.inner));
case "union": {
// Unions have an id, so a cycle that names one is answered from the
// memo above; the identity guard covers a cycle that does not.
const key = `union:${t.unionId}`;
const known = seen.get(key);
if (known !== undefined) return known;
return descend(t, () => {
seen.set(key, false);
const result = lowerer.unions.get(t.unionId)?.arms.some(holds) === true;
seen.set(key, result);
return result;
});
}
default:
return false;
}
@@ -3382,10 +3382,13 @@ export function lowerTuplePredicateCall(
* of the receiver's length when it starts. ArraySpeciesCreate reads the
* receiver's length before the species constructor runs, so a constructor
* that appends to the receiver does not extend the loop
* (lowerWithArraySpecies). Null when `name` is no such helper. */
* (lowerWithArraySpecies). Every helper that reads a length is one, the
* flatMap whose callback returns a union included, whether or not a
* pipeline can fuse it (arrHofInfo holds the fusable ones); forEach makes
* no array, so it stays out. Null when `name` is no such helper. */
export function boundedArrayHofHelper(lowerer: Lowerer, name: string): string | null {
const info = lowerer.arrHofInfo.get(name);
if (!info || info.method === "forEach") return null;
if (!name.startsWith("%arr.")) return null;
if (lowerer.arrHofInfo.get(name)?.method === "forEach") return null;
const bounded = `${name}.bounded`;
if (lowerer.liftedFnNamed(bounded)) return bounded;
const fn = lowerer.liftedFnNamed(name);
@@ -4892,9 +4892,10 @@ const DYN_STRING_ONLY_METHODS = new Set([
/** `Array.isArray(v)` — a real runtime test on `unknown` values (the checked-dynamic tree's
* array kind: dyn arrays answer true, bytes/objects/scalars false — exactly
* JS, Uint8Array included), a compile-time constant on statically-typed
* ones (an `T[]` value IS an array, every other static kind is not; folded
* only over side-effect-free reads, the `in`-operator discipline; unions
* fence with the narrow-first hint). Null when the callee isn't THE
* ones (an `T[]` value IS an array, every other static kind is not; a
* computed argument is evaluated and its value discarded, the
* `in`-operator discipline; unions fence with the narrow-first hint). Null
* when the callee isn't THE
* stdlib Array.isArray, so the chain keeps trying. */
function lowerArrayIsArrayCall(
lowerer: Lowerer,
@@ -4964,14 +4965,23 @@ function lowerArrayIsArrayCall(
);
}
if (arg.type.kind === "caught") return null;
if (arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet") {
return { kind: "boolLit", value: lowerer.isArrayValueType(arg.type), type: BOOL, loc };
}
lowerer.unsupported(
"SC1090",
call,
"statically-decided Array.isArray on computed arguments (bind the value to a variable first)",
);
const folded: IrExpr = {
kind: "boolLit",
value: lowerer.isArrayValueType(arg.type),
type: BOOL,
loc,
};
if (arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet") return folded;
// A computed argument (`Array.isArray(make())`, `Array.isArray([1, 2])`)
// still answers from its static type, so evaluate the argument for its
// effects and discard the value.
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: arg, loc }],
result: folded,
type: BOOL,
loc,
};
}
/** Predicate declarations currently being inlined — re-entrancy guard
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8420-x509-recursive-dyn-type.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8421-array-subclass-species-forms.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8422-array-isarray-computed.ts"
],
"diags": []
}
@@ -0,0 +1,42 @@
// A recursive type that cycles through an array and a set, next to a
// certificate: the fence that refuses an X509Certificate in an `unknown`
// slot (x509.ts) walks the converted value's type, and a cycle through
// anything but a record used to recurse until the stack ran out — a crash,
// not a diagnostic. The types are named here the way a JSON-ish union is,
// and the certificate is the one 7090 parses.
import { X509Certificate } from "node:crypto";
// A self-signed certificate without alternative names (as in 7090).
const PLAIN = `-----BEGIN CERTIFICATE-----
MIIC8jCCAdoCAQcwDQYJKoZIhvcNAQELBQAwPjEaMBgGA1UEAwwRcGxhaW4uZXhh
bXBsZS5vcmcxIDAeBgkqhkiG9w0BCQEWEXBsYWluQGV4YW1wbGUub3JnMCAXDTI2
MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjA+MRowGAYDVQQDDBFwbGFpbi5l
eGFtcGxlLm9yZzEgMB4GCSqGSIb3DQEJARYRcGxhaW5AZXhhbXBsZS5vcmcwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq01NcNAy9Ng04mVuAFvmZUvsm
rY7aOaMgtODmptulgZ2qw25E09RgGYCn1S+4IYDu9hH9X4s+28Ag7Khmj4nmeoXN
of5095wche8DR2AAUElqyr/Nmgm2kTLdIJX4wl0eWXHIlmSPdOUNovA6JjGKH99b
8W7a2TqY/TsSadpPSvvN2esr7Jr4KgW7MXPUOnwNbcBOVcQP9rtFrduAWz01q0HM
02LboclnUnk/e58BXAj8vgdVojljDcc0bhbdz/Hh1/5gaaNILjJNOd4AgwZaCT02
KojGz4Nb6MSgFmON2iMgAZjY1fXXkJ7wsfVPytjW73e7uV/RFuqSHQZenxupAgMB
AAEwDQYJKoZIhvcNAQELBQADggEBAJX1DGuiSLoCKBDI3oCgAu7X86G01ovEgZJr
8JrmGHW5q7ujjo53t46h8XPm3LJ8UV9/MZ+Yry43xB2txXYv3b3g/i+7JdTWLgUD
INA5VWvVkEZfm0lBEpU1Agpy4lV5uUCKZTgP/3MY5hnJ0YhQIs4BOjbwueMRFshL
yyHVb73MMbpWaZ7+T368seafa+ekeuY6gQly/HacuszjoYd6KKV3n8eDewenClCF
HoOF1Y4I+CvmR+a6B3iBczJ+a4tceFIIWsZUIeqzvGHpM7G9YwlmbvGprfLgR8Nv
igUib/D9tU3hdL6VWN5nMjdXV/8Le4nDnBjVBjYDkVbsnmT91AQ=
-----END CERTIFICATE-----
`;
type Rec = string | Rec[];
type RecSet = number | Set<RecSet>;
const cert = new X509Certificate(PLAIN);
const rec: Rec = ["a", ["b"]];
const set: RecSet = new Set([1, 2]);
function describe(value: unknown): string {
return typeof value;
}
console.log(describe(rec), rec.length, describe(set), set.size);
console.log(cert.subject);
@@ -0,0 +1,32 @@
// One class that extends Array sends every array method call in the program
// through the species lowering, plain-array receivers included. Two callback
// forms the species path used to refuse: a filter whose callback narrows the
// element union (a type predicate, or a discriminant test tsc infers one
// from), which wraps the filtered array in a checked-narrowing conversion,
// and a flatMap whose callback returns a union holding an array arm.
class Stack extends Array<number> {}
type Item = { kind: "a"; n: number } | { kind: "b"; s: string };
const items: Item[] = [
{ kind: "a", n: 1 },
{ kind: "b", s: "x" },
{ kind: "a", n: 2 },
];
const isA = (item: Item): item is { kind: "a"; n: number } => item.kind === "a";
const tagged = items.filter(isA);
const inferred = items.filter((item) => item.kind === "b");
const blocked = items.filter((item) => {
return item.kind === "b";
});
const flattened = items.flatMap((item) => (item.kind === "a" ? [item.n] : []));
const mixed = items.flatMap((item) => (item.kind === "a" ? [item.n] : undefined));
console.log(tagged.length, tagged.map((item) => item.n).join(","));
console.log(inferred.length, inferred.map((item) => item.s).join(","), blocked.length);
console.log(flattened.join(","), mixed.length, mixed.join(","));
const stack = Stack.from([1, 2, 3]);
const even = stack.filter((n) => n % 2 === 0);
const doubled = stack.flatMap((n) => (n > 1 ? [n, n * 10] : []));
console.log(even instanceof Stack, even.join(","), stack.length);
console.log(doubled instanceof Stack, doubled.join(","), stack.length);
@@ -0,0 +1,28 @@
// `Array.isArray` over a computed argument: a call, an array literal, an
// object literal. The answer comes from the argument's static type, so the
// argument is evaluated for its effects and its value discarded. A union
// argument keeps answering by its runtime tag, and side effects of the
// argument survive the fold.
function rows(): number[] {
return [1, 2];
}
function label(): string {
return "rows";
}
type Pair = [number, string];
function pair(): Pair {
return [3, "three"];
}
let evals = 0;
function counted(): number[] {
evals++;
return [];
}
console.log(Array.isArray(rows()), Array.isArray([1, 2]), Array.isArray({ a: 1 }));
console.log(Array.isArray(label()), Array.isArray(pair()));
console.log(Array.isArray(counted()), evals);
const maybe: string | string[] = ["a"];
const narrow: string | string[] = "a";
console.log(Array.isArray(maybe), Array.isArray(narrow));
console.log(Array.isArray(rows().map((n) => n * 2)));
+1 -1
View File
@@ -12,7 +12,7 @@ const g = globalThis;
const nan = NaN; // lowers now — a non-finite numeric literal like Infinity
const s5 = String(5);
const n5 = Number.NaN; // lowers now, with the other Number constants
const isArr = Array.isArray([1]);
const isArr = Array.isArray([1]); // lowers now: a computed argument folds after it is evaluated
const merged = Object.assign({}, { a: 1 }); // the empty-target literal-source shape lowers now (it IS the source literal)
const aliasedAssign = Object.assign(merged, { b: 2 }); // aliased targets stay fenced (real mutation of a live object)
const refl = Reflect.has({ a: 1 }, "a");
@@ -1,10 +1,3 @@
stdlib-fence.ts:15:15 - error SC1090: statically-decided Array.isArray on computed arguments (bind the value to a variable first) are not supported yet
14 | const n5 = Number.NaN; // lowers now, with the other Number constants
15 | const isArr = Array.isArray([1]);
| ^~~~~~~~~~~~~~~~~~
16 | const merged = Object.assign({}, { a: 1 }); // the empty-target literal-source shape lowers now (it IS the source literal)
stdlib-fence.ts:17:23 - error SC2020: 'Object.assign' is part of the standard library types but has no scriptc lowering yet
16 | const merged = Object.assign({}, { a: 1 }); // the empty-target literal-source shape lowers now (it IS the source literal)
@@ -0,0 +1 @@
{ "plain": 2100, "san": 1600 }
@@ -0,0 +1 @@
{ "plain": 1850, "san": 1250 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 1375 }