Skip to content

Commit bdf8843

Browse files
committed
fix: make web file values opaque
1 parent c673b21 commit bdf8843

10 files changed

Lines changed: 248 additions & 19 deletions

File tree

‎docs/compiler-architecture.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ for `match`; an asserted binding shape fails immediately.
217217
contextual project-editor seams;
218218
extensions declare opaque primitive types and their parent relations through
219219
the generic analysis contract, so Core assignability contains no Web type
220-
names;
220+
names; primitive members are read-only unless their owning extension
221+
explicitly marks a field writable;
221222
loading lexical keywords alone can never silently activate Web semantics.
222223
Its `framework-host` subpath is a platform-neutral tooling ABI containing
223224
only protocol types and a version constant; it does not implement HTML,

‎docs/web-api.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -483,8 +483,9 @@ if selected.size > 0:
483483
```
484484

485485
`pick` uses the native file-input path supported by Chromium, Firefox, and
486-
WebKit. It returns stable VelarScript file records with `name`, `size`, `type`, and
487-
`modified`; only records returned by `pick` may be read with `readText` or
486+
WebKit. It returns `List<File>`, where opaque `File` values expose read-only
487+
`name`, `size`, `type`, and `modified` metadata; only values returned by `pick`
488+
may be read with `readText` or
488489
`readDataUrl` or attached to `velar/http.formBody`. Picker options are a
489490
data-only `accept` string/`multiple` bool record. `download` accepts actual text
490491
for its name, data, and MIME type. Invalid options, forged file records, and
@@ -497,7 +498,7 @@ persistent file handles, and the File System Access API are deliberately not
497498
part of Web API 0.10.
498499

499500
Returned file names/MIME types, sizes, and modification times are validated
500-
before an opaque record is registered. Invalid native picker results reject
501+
before an opaque `File` is registered. Invalid native picker results reject
501502
the Promise instead of escaping an event callback or leaving it pending.
502503
`readText` and `readDataUrl` also verify the asynchronous reader result and its
503504
maximum encoded expansion before returning a string.

‎packages/compiler/src/analyzer.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,14 @@ const coreGlobalGuidance = new Map([
253253
["String", "Use str(value) instead of the JavaScript String global"],
254254
]);
255255

256+
export function isCorePrimitiveName(name: string): boolean {
257+
return corePrimitiveNames.has(name);
258+
}
259+
260+
export function isCoreReservedBinding(name: string): boolean {
261+
return reservedBindings.has(name);
262+
}
263+
256264
export class Analyzer implements TypeEnvironment {
257265
protected readonly diagnostics: Diagnostic[] = [];
258266
private readonly scopes: Map<string, Binding>[] = [new Map()];
@@ -341,6 +349,7 @@ export class Analyzer implements TypeEnvironment {
341349
private callableOriginChanged = false;
342350
private readonly primitiveNames = new Set(corePrimitiveNames);
343351
private readonly primitiveParents = new Map<string, Set<string>>();
352+
private readonly primitiveMutableFields = new Map<string, Set<string>>();
344353
private readonly extensionGlobals = new Map<string, ValueType>();
345354
private readonly extensionReservedBindings = new Set<string>();
346355
private readonly globalGuidance = new Map(coreGlobalGuidance);
@@ -383,6 +392,11 @@ export class Analyzer implements TypeEnvironment {
383392
for (const parent of parents) collected.add(parent);
384393
this.primitiveParents.set(name, collected);
385394
}
395+
for (const [name, fields] of extension.primitiveMutableFields ?? []) {
396+
const collected = this.primitiveMutableFields.get(name) ?? new Set<string>();
397+
for (const field of fields) collected.add(field);
398+
this.primitiveMutableFields.set(name, collected);
399+
}
386400
for (const [name, type] of extension.globals ?? []) this.extensionGlobals.set(name, type);
387401
for (const name of extension.reservedBindings ?? []) this.extensionReservedBindings.add(name);
388402
for (const [name, guidance] of extension.globalGuidance ?? []) this.globalGuidance.set(name, guidance);
@@ -3387,6 +3401,11 @@ export class Analyzer implements TypeEnvironment {
33873401
} else if (owner.kind === "object" && owner.readonlyFields?.has(statement.target.property)) {
33883402
this.diagnostics.push(diagnostic("VEL3002", `Cannot assign to read-only field '${statement.target.property}'`, statement.target.span));
33893403
targetWritable = false;
3404+
} else if (owner.kind === "named" && this.primitiveNames.has(owner.name)
3405+
&& this.fieldsOf(owner.identity ?? owner.name)?.has(statement.target.property)
3406+
&& !this.primitiveFieldWritable(owner.name, statement.target.property)) {
3407+
this.diagnostics.push(diagnostic("VEL3002", `Cannot assign to read-only member '${statement.target.property}'`, statement.target.span));
3408+
targetWritable = false;
33903409
} else if (owner.kind === "object" && owner.optionalFields?.has(statement.target.property)) {
33913410
targetType = owner.fields.get(statement.target.property) ?? targetType;
33923411
}
@@ -3444,6 +3463,19 @@ export class Analyzer implements TypeEnvironment {
34443463
}
34453464
}
34463465

3466+
private primitiveFieldWritable(name: string, field: string): boolean {
3467+
const pending = [name];
3468+
const visited = new Set<string>();
3469+
while (pending.length > 0) {
3470+
const current = pending.pop()!;
3471+
if (visited.has(current)) continue;
3472+
visited.add(current);
3473+
if (this.primitiveMutableFields.get(current)?.has(field)) return true;
3474+
for (const parent of this.primitiveParents.get(current) ?? []) pending.push(parent);
3475+
}
3476+
return false;
3477+
}
3478+
34473479
protected inferExpression(expression: Expression, contextualType: ValueType = unknownType): ValueType {
34483480
const type = this.inferExpressionType(expression, contextualType);
34493481
this.inferredExpressionTypes.set(spanIdentity(expression.span), type);

‎packages/compiler/src/extension.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export interface CompilerLexicalScanResult {
7474
export interface CompilerAnalysisExtension {
7575
readonly primitiveTypes?: ReadonlySet<string>;
7676
readonly primitiveParents?: ReadonlyMap<string, ReadonlySet<string>>;
77+
readonly primitiveMutableFields?: ReadonlyMap<string, ReadonlySet<string>>;
7778
readonly globals?: ReadonlyMap<string, ValueType>;
7879
readonly reservedBindings?: ReadonlySet<string>;
7980
readonly globalGuidance?: ReadonlyMap<string, string>;

‎packages/compiler/src/index.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Analyzer, type AnalysisContext, type ClassInfo } from "./analyzer.ts";
1+
import { Analyzer, isCorePrimitiveName, isCoreReservedBinding, type AnalysisContext, type ClassInfo } from "./analyzer.ts";
22
import type { BindingPattern, Expression, FunctionDeclaration, MatchPattern, Program, Statement, TypeReference } from "./ast.ts";
33
import { diagnostic, type Diagnostic } from "./diagnostic.ts";
44
import { JavaScriptEmitter } from "./emitter.ts";
@@ -213,6 +213,8 @@ function parseModule(text: string, path: string, extensions: readonly CompilerEx
213213
function normalizedExtensions(extensions: readonly CompilerExtension[]): readonly CompilerExtension[] {
214214
const seen = new Set<string>();
215215
const capabilities = new Set<string>();
216+
const primitiveOwners = new Map<string, string>();
217+
const globalOwners = new Map<string, string>();
216218
for (const extension of extensions) {
217219
if (!extension.id || seen.has(extension.id)) throw new Error(`Compiler extension '${extension.id}' is invalid or duplicated`);
218220
seen.add(extension.id);
@@ -222,7 +224,56 @@ function normalizedExtensions(extensions: readonly CompilerExtension[]): readonl
222224
}
223225
capabilities.add(capability);
224226
}
227+
for (const name of extension.analysis?.primitiveTypes ?? []) {
228+
const owner = primitiveOwners.get(name);
229+
if (isCorePrimitiveName(name)) throw new Error(`Compiler extension '${extension.id}' cannot replace Core primitive '${name}'`);
230+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) || owner) {
231+
throw new Error(`Compiler primitive '${name}' is invalid or has more than one owner${owner ? ` (${owner}, ${extension.id})` : ""}`);
232+
}
233+
primitiveOwners.set(name, extension.id);
234+
}
235+
for (const name of extension.analysis?.globals?.keys() ?? []) {
236+
if (isCoreReservedBinding(name)) throw new Error(`Compiler extension '${extension.id}' cannot replace reserved Core binding '${name}'`);
237+
const owner = globalOwners.get(name);
238+
if (owner) throw new Error(`Compiler global '${name}' has more than one owner (${owner}, ${extension.id})`);
239+
globalOwners.set(name, extension.id);
240+
}
241+
}
242+
243+
const parents = new Map<string, Set<string>>();
244+
for (const extension of extensions) {
245+
for (const [name, values] of extension.analysis?.primitiveParents ?? []) {
246+
if (primitiveOwners.get(name) !== extension.id) {
247+
throw new Error(`Compiler extension '${extension.id}' cannot define parents for primitive '${name}' that it does not own`);
248+
}
249+
const collected = parents.get(name) ?? new Set<string>();
250+
for (const parent of values) {
251+
if (!primitiveOwners.has(parent)) throw new Error(`Compiler primitive '${name}' has unknown parent '${parent}'`);
252+
collected.add(parent);
253+
}
254+
parents.set(name, collected);
255+
}
256+
for (const [name, fields] of extension.analysis?.primitiveMutableFields ?? []) {
257+
if (primitiveOwners.get(name) !== extension.id) {
258+
throw new Error(`Compiler extension '${extension.id}' cannot make fields writable on primitive '${name}' that it does not own`);
259+
}
260+
for (const field of fields) {
261+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(field)) throw new Error(`Compiler primitive '${name}' has invalid writable field '${field}'`);
262+
}
263+
}
225264
}
265+
266+
const completed = new Set<string>();
267+
const active = new Set<string>();
268+
const visit = (name: string): void => {
269+
if (completed.has(name)) return;
270+
if (active.has(name)) throw new Error(`Compiler primitive inheritance contains a cycle at '${name}'`);
271+
active.add(name);
272+
for (const parent of parents.get(name) ?? []) visit(parent);
273+
active.delete(name);
274+
completed.add(name);
275+
};
276+
for (const name of primitiveOwners.keys()) visit(name);
226277
return extensions;
227278
}
228279

‎packages/web/src/analyzer.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,7 @@ function webTypeFields(name: string): ReadonlyMap<string, ValueType> | null {
10211021
if (name === "PointerEvent") return new Map([...eventFields(), ["pointerId", numberType], ["pointerType", stringType], ["pressure", numberType], ["button", numberType], ["buttons", numberType], ["clientX", numberType], ["clientY", numberType], ["movementX", numberType], ["movementY", numberType], ["altKey", boolType], ["ctrlKey", boolType], ["metaKey", boolType], ["shiftKey", boolType]]);
10221022
if (name === "InputEvent") return new Map([...eventFields(), ["data", optionalOf(stringType)], ["inputType", stringType], ["isComposing", boolType]]);
10231023
if (name === "Blob") return new Map();
1024+
if (name === "File") return new Map([["name", stringType], ["size", numberType], ["type", stringType], ["modified", numberType]]);
10241025
if (name === "Element" || name === "InputElement" || name === "CanvasElement" || name === "DialogElement") {
10251026
const fields = new Map<string, ValueType>([["focus", functionType([], [], nullType)], ["remove", functionType([], [], nullType)]]);
10261027
if (name === "InputElement") { fields.set("value", stringType); fields.set("checked", boolType); }

‎packages/web/src/compiler.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const cleanupType = namedFunction([], [], nullType);
8686
const arrayString: ValueType = { kind: "list", element: stringType };
8787
const mapString = (value: ValueType): ValueType => ({ kind: "map", key: stringType, value });
8888
const webElementType: ValueType = { kind: "union", members: [elementType, inputElementType, canvasElementType, dialogElementType] };
89-
const fileType = object({ name: stringType, size: numberType, type: stringType, modified: numberType });
89+
const fileType: ValueType = { kind: "named", name: "File" };
9090
const fileArrayType: ValueType = { kind: "list", element: fileType };
9191
const formBodyType = object({
9292
field: namedFunction(["name", "value"], [stringType, stringType], nullType),
@@ -420,7 +420,7 @@ export const velarCompilerExtension: CompilerExtension = Object.freeze({
420420
semantic: velarWebSemanticExtension,
421421
inspection: velarWebInspectionExtension,
422422
analysis: Object.freeze({
423-
primitiveTypes: new Set(["WebNode", "Element", "InputElement", "CanvasElement", "DialogElement", "Blob", "Event", "KeyboardEvent", "PointerEvent", "InputEvent", "Look", "Length", "Percentage", "Color", "Duration", "Angle", "Opacity", "Border", "Shadow", "Image", "Track", "TrackList", "Transition", "Spacing"]),
423+
primitiveTypes: new Set(["WebNode", "Element", "InputElement", "CanvasElement", "DialogElement", "Blob", "File", "Event", "KeyboardEvent", "PointerEvent", "InputEvent", "Look", "Length", "Percentage", "Color", "Duration", "Angle", "Opacity", "Border", "Shadow", "Image", "Track", "TrackList", "Transition", "Spacing"]),
424424
primitiveParents: new Map([
425425
["InputElement", new Set(["Element"])],
426426
["CanvasElement", new Set(["Element"])],
@@ -429,6 +429,10 @@ export const velarCompilerExtension: CompilerExtension = Object.freeze({
429429
["PointerEvent", new Set(["Event"])],
430430
["InputEvent", new Set(["Event"])],
431431
]),
432+
primitiveMutableFields: new Map([
433+
["InputElement", new Set(["value", "checked"])],
434+
["CanvasElement", new Set(["width", "height"])],
435+
]),
432436
globals: webGlobals,
433437
reservedBindings: new Set(["mount", "tick"]),
434438
globalGuidance: new Map([
@@ -461,6 +465,7 @@ export const velarCompilerExtension: CompilerExtension = Object.freeze({
461465
CanvasElement: "A native canvas reference obtained through JSX `ref`.",
462466
DialogElement: "A native dialog reference obtained from `<dialog ref={value}>` and operated through `velar/browser`.",
463467
Blob: "An opaque binary HTTP body returned by `blob()` and accepted by HTTP request bodies.",
468+
File: "An opaque selected file returned by `velar/files.pick()` with checked read-only metadata.",
464469
Event: "A restricted Web event value exposed to VelarScript event handlers.",
465470
Look: "A typed, composable Web appearance value applied through JSX look={...}.",
466471
}),

‎packages/web/src/emitter.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ interface LookRule {
4141
readonly staticAtoms: readonly LookStaticAtom[];
4242
}
4343

44+
const FILE_TYPE_RUNTIME = String.raw`
45+
function __velarFileTypeIs(value) {
46+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, Symbol.for("velar.file.registry.v1"));
47+
if (!descriptor || !("value" in descriptor) || descriptor.configurable || descriptor.enumerable || descriptor.writable) return false;
48+
try { return WeakMap.prototype.has.call(descriptor.value, value); }
49+
catch { return false; }
50+
}
51+
`.trimStart();
52+
4453
export class WebJavaScriptEmitter extends JavaScriptEmitter {
4554
private readonly reactive = new Map<string, "state" | "computed">();
4655
private currentScope: string | null = null;
@@ -49,6 +58,7 @@ export class WebJavaScriptEmitter extends JavaScriptEmitter {
4958
private cssOutput = "";
5059
private cssSegments: CompilerStyleSegments = { before: "", controlled: "", after: "" };
5160
private webOutput = false;
61+
private needsFileTypeHelper = false;
5262
private jsxId = 0;
5363

5464
constructor(
@@ -66,6 +76,7 @@ export class WebJavaScriptEmitter extends JavaScriptEmitter {
6676
for (const [name, kind] of this.hints.reactiveBindings) this.reactive.set(name, kind);
6777
this.prepareLooks(program);
6878
this.webOutput = containsWebSyntax(program);
79+
this.needsFileTypeHelper = false;
6980
return super.emit(program);
7081
}
7182

@@ -93,12 +104,19 @@ export class WebJavaScriptEmitter extends JavaScriptEmitter {
93104
return `((typeof HTMLInputElement !== "undefined" && ${value} instanceof HTMLInputElement) || (typeof HTMLSelectElement !== "undefined" && ${value} instanceof HTMLSelectElement) || (typeof HTMLTextAreaElement !== "undefined" && ${value} instanceof HTMLTextAreaElement))`;
94105
}
95106
if (type.name === "Blob") return `(typeof Blob !== "undefined" && ${value} instanceof Blob)`;
107+
if (type.name === "File") {
108+
this.needsFileTypeHelper = true;
109+
return `__velarFileTypeIs(${value})`;
110+
}
96111
}
97112
return super.emitTypeCheck(type, value, state);
98113
}
99114

100115
protected override additionalHelpers(program: Program): readonly string[] {
101-
return this.webOutput ? [WEB_RUNTIME] : [];
116+
return [
117+
...(this.webOutput ? [WEB_RUNTIME] : []),
118+
...(this.needsFileTypeHelper ? [FILE_TYPE_RUNTIME] : []),
119+
];
102120
}
103121

104122
protected override includesErrorNormalizationRuntime(): boolean {

‎packages/web/src/runtime.ts‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ function __velarInvokeOwnedCallback(callback, arguments_, phase, detail) {
1919
}
2020
`.trimStart();
2121

22+
const fileRegistryRuntime = String.raw`
23+
const nativeFilesKey = Symbol.for("velar.file.registry.v1");
24+
const nativeFiles = (() => {
25+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, nativeFilesKey);
26+
if (descriptor) {
27+
if (!("value" in descriptor)) throw new TypeError("VelarScript file registry cannot be an accessor");
28+
if (descriptor.configurable || descriptor.enumerable || descriptor.writable) throw new TypeError("VelarScript file registry ownership is invalid");
29+
try { WeakMap.prototype.has.call(descriptor.value, descriptor.value); }
30+
catch { throw new TypeError("VelarScript file registry is invalid"); }
31+
return descriptor.value;
32+
}
33+
const registry = new WeakMap();
34+
Object.defineProperty(globalThis, nativeFilesKey, { value: registry, enumerable: false, configurable: false, writable: false });
35+
return registry;
36+
})();
37+
`.trimStart();
38+
2239
const strictJsonRuntime = String.raw`
2340
const __velarMaxJsonCodeUnits = 16 * 1024 * 1024;
2441
const __velarMaxJsonNodes = 1000000;
@@ -1017,8 +1034,7 @@ ${listRuntime}
10171034
${optionsRuntime}
10181035
${strictJsonRuntime}
10191036
${runtimeTypeRuntime}
1020-
const nativeFilesKey = Symbol.for("velar.file.registry.v1");
1021-
const nativeFiles = globalThis[nativeFilesKey] ??= new WeakMap();
1037+
${fileRegistryRuntime}
10221038
const formBodies = new WeakMap();
10231039
10241040
function runtimeHttpType(Type) { return __velarRequireRuntimeType(Type, "HTTP parsing"); }
@@ -1088,7 +1104,7 @@ function fieldName(value) {
10881104
}
10891105
10901106
function nativeFile(value) {
1091-
const file = value && nativeFiles.get(value);
1107+
const file = value && WeakMap.prototype.get.call(nativeFiles, value);
10921108
if (!(file instanceof File)) throw new TypeError("Form body files must come from velar/files pick()");
10931109
return file;
10941110
}
@@ -1588,8 +1604,7 @@ function requireDialog(value) { if (typeof HTMLDialogElement === "undefined" ||
15881604
`.trimStart()],
15891605
["velar/files", String.raw`
15901606
${optionsRuntime}
1591-
const nativeFilesKey = Symbol.for("velar.file.registry.v1");
1592-
const nativeFiles = globalThis[nativeFilesKey] ??= new WeakMap();
1607+
${fileRegistryRuntime}
15931608
const defaultFileReadBytes = 16 * 1024 * 1024;
15941609
const maxFileReadBytes = 64 * 1024 * 1024;
15951610
function readLimit(value) {
@@ -1606,10 +1621,10 @@ function wrap(file) {
16061621
if (!Number.isFinite(modified) || modified < 0) throw new TypeError("Selected file modified time must be a non-negative finite number");
16071622
const value = { name, size, type, modified };
16081623
Object.freeze(value);
1609-
nativeFiles.set(value, file);
1624+
WeakMap.prototype.set.call(nativeFiles, value, file);
16101625
return value;
16111626
}
1612-
function native(file) { const value = nativeFiles.get(file); if (!value) throw new TypeError("Expected a file returned by velar/files"); return value; }
1627+
function native(file) { const value = WeakMap.prototype.get.call(nativeFiles, file); if (!value) throw new TypeError("Expected a file returned by velar/files"); return value; }
16131628
export function pick(options = {}) {
16141629
options = __velarOptions(options, "File picker options", new Set(["accept", "multiple"]));
16151630
const accept = options.accept == null ? "" : __velarString(options.accept, "File accept filter");

0 commit comments

Comments
 (0)