mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-04 08:04:37 +08:00
Cross island records of intersection types out as copies
An island export whose declared type is an INTERSECTION of record types —
typebox's `Type.Unsafe<{[K in "fg" | "bg"]: V} & {[K in "accent"]?: V}>`,
the mapped halves of pi's ThemeColorValues read off the island's theme
schema — was refused with SC8010 ("an island value of type … only primitives
and strings are copied"). The crossing classified values with
exportedByReference, which requires the object flag, so an intersection of
records looked like a value with no native representation even though
mapTypeOf maps it to the record its parts make.
The copy decision for a value crossing OUT is now copiedRecordType: the
object types exportedByReference names, and intersections of them (nullish
arms aside). Such a value crosses as the deep checked copy the record path
already uses, so a field that contradicts the declaration throws
`TypeError: expected string at $.fg, got number` instead of entering
natively typed memory (types don't lie). Copy, not view, for the reason
9a8e964 chose for arrays: nothing native can alias the island's object.
Test: tests/corpus/6899-node-island-unsafe-record (differential to Node,
plain and sanitized) and the lying-record case in
tests/harness/node-island-handles.test.ts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
52ef883a48
commit
ea9b70d1ad
@@ -739,7 +739,7 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr
|
||||
};
|
||||
return lowerer.coerceToExpected(copy, mapped);
|
||||
}
|
||||
if (!copiedType(lowerer, mapped) && exportedByReference(lowerer, tsType))
|
||||
if (!copiedType(lowerer, mapped) && copiedRecordType(lowerer, tsType))
|
||||
return lowerer.coerceToExpected(raw, mapped);
|
||||
if (!copiedType(lowerer, mapped))
|
||||
boundaryFail(
|
||||
@@ -1245,6 +1245,25 @@ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A record type an island object crosses OUT into as a deep checked
|
||||
* copy: the object types exportedByReference names, and intersections of
|
||||
* them — a mapped pair (`{[K in Fg]: V} & {[K in Bg]?: V}`) or a merged
|
||||
* generic record (`Omit<Base, "k"> & Extras`) is one record, so the
|
||||
* island's object converts into the native one the same way a plain
|
||||
* record's does. An intersection with a primitive arm (`string & {brand}`)
|
||||
* is a primitive value, which copies as its arm. */
|
||||
function copiedRecordType(lowerer: Lowerer, type: ts.Type): boolean {
|
||||
if ((type.flags & ts.TypeFlags.Intersection) !== 0)
|
||||
return ts
|
||||
.constituentTypes(type)
|
||||
.every(
|
||||
(t) =>
|
||||
(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 ||
|
||||
copiedRecordType(lowerer, t),
|
||||
);
|
||||
return exportedByReference(lowerer, type);
|
||||
}
|
||||
|
||||
/** A fresh object or array literal crosses as a copy (a plain dyn object
|
||||
* or array the runtime copies into V8): nothing native can alias it. Its
|
||||
* members cross by the same rules (toIsland). */
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6899-node-island-unsafe-record/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// @requires-libnode
|
||||
// A record the island hands back (`unsafe<T>` declares the caller's own
|
||||
// type, like typebox's `Type.Unsafe<T>`) crosses out as a deep checked
|
||||
// copy: the merged type is a required half and an optional half, and the
|
||||
// native value carries every field the declaration promises.
|
||||
import { unsafe } from "recordkit";
|
||||
|
||||
/** A mapped pair of halves — one record, spelled as an intersection. */
|
||||
type ColorValues<Value> = { [K in "fg" | "bg"]: Value } & { [K in "accent"]?: Value };
|
||||
|
||||
const colors = unsafe<ColorValues<string>>({ fg: "red", bg: "blue" });
|
||||
console.log(colors.fg, colors.bg, colors.accent === undefined, Object.keys(colors).join(","));
|
||||
|
||||
const levels = unsafe<ColorValues<number>>({ fg: 1, bg: 2, accent: 3 });
|
||||
console.log(levels.fg + levels.bg, levels.accent, Object.keys(levels).join(","));
|
||||
|
||||
/** Native code reads the copy's fields by their declared types. */
|
||||
function describe(values: ColorValues<string>): string {
|
||||
return `${values.fg}/${values.bg}/${values.accent ?? "-"}`;
|
||||
}
|
||||
console.log(describe(colors), describe({ fg: "green", bg: "black" }));
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export interface Schema {
|
||||
readonly kind: string;
|
||||
describe(): string;
|
||||
}
|
||||
/** Declares the type of the value it is given, like typebox's
|
||||
* `Type.Unsafe<T>`: the island hands back the caller's own type, so the
|
||||
* caller's declaration must describe what the island really returns. */
|
||||
export declare function unsafe<T>(value: T): T;
|
||||
/** A schema over the properties record it is given, like `Type.Object`. */
|
||||
export declare function object<T>(properties: T): Schema & { readonly properties: T };
|
||||
/** The sorted own keys of a value, joined. */
|
||||
export declare function keys(value: unknown): string;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
exports.unsafe = (value) => value;
|
||||
exports.object = (properties) => ({
|
||||
kind: "object",
|
||||
describe: () => "object " + Object.keys(properties).sort().join(","),
|
||||
properties,
|
||||
});
|
||||
exports.keys = (value) => Object.keys(value).sort().join(",");
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "recordkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "node-island-unsafe-record", "private": true, "type": "module", "scriptc": { "packages": { "recordkit": "node" } } }
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// `unsafe<T>` declares the caller's own type and hands back the value it
|
||||
// was given: the record crossing out is a deep CHECKED copy, so a field
|
||||
// that contradicts the declaration throws a TypeError instead of entering a
|
||||
// native record (a corpus program cannot pin this: Node prints the field).
|
||||
import { unsafe } from "recordkit";
|
||||
|
||||
type ColorValues = { [K in "fg" | "bg"]: string } & { [K in "accent"]?: string };
|
||||
|
||||
const raw: unknown = JSON.parse('{"fg":1,"bg":"blue"}');
|
||||
console.log("before");
|
||||
const colors = unsafe<ColorValues>(raw as ColorValues);
|
||||
console.log("after", colors.fg);
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2900, "san": 33000 }
|
||||
@@ -210,6 +210,32 @@ describe.skipIf(skip)("Node island handles", () => {
|
||||
);
|
||||
}, 300_000);
|
||||
|
||||
test("an island record that contradicts its declaration throws at the crossing", async () => {
|
||||
// A record the island hands back crosses as a deep checked copy:
|
||||
// `unsafe<T>` declares the caller's own record type and hands back a
|
||||
// JSON object whose `fg` is a number, so the copy's conversion throws
|
||||
// before a number reaches a native string field.
|
||||
const root = join(outDir, "unsafe-record-lie");
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
cpSync(join(repoRoot, "tests/corpus/6899-node-island-unsafe-record"), root, {
|
||||
recursive: true,
|
||||
});
|
||||
copyFileSync(
|
||||
join(repoRoot, "tests/fixtures/node-island/unsafe-record-lie.ts"),
|
||||
join(root, "main.ts"),
|
||||
);
|
||||
const result = await compile(join(root, "main.ts"), {
|
||||
outPath: join(root, "main"),
|
||||
outDir: root,
|
||||
sanitize,
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
expect(await refusal(result.binaryPath)).toContain(
|
||||
"TypeError: expected string at $.fg, got number",
|
||||
);
|
||||
}, 300_000);
|
||||
|
||||
test("an island package ships with the program", async () => {
|
||||
const root = join(outDir, "ships");
|
||||
const src = join(root, "src");
|
||||
|
||||
Reference in New Issue
Block a user