Skip to content

Commit 0db7457

Browse files
fix(engine): natively construct DOMException and URIError on boundary crossing
Resolves the D3a deferral by dynamically resolving JS exception constructors (like globalThis.DOMException) when marshaling native exceptions, allowing host op errors to surface as true instances to JavaScript.
1 parent 6243059 commit 0db7457

5 files changed

Lines changed: 106 additions & 12 deletions

File tree

crates/engine/src/convert.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,38 @@ pub(crate) fn build_exception<'s>(
8585
err: &dyn IntoException,
8686
) -> v8::Local<'s, v8::Value> {
8787
let class = err.exception_class();
88+
89+
// Fallback/dynamic constructor lookup for classes V8 doesn't provide natively.
90+
let try_construct = |scope: &mut v8::PinScope<'s, '_>, class_name: &str, args: &[v8::Local<'s, v8::Value>]| -> Option<v8::Local<'s, v8::Value>> {
91+
let context = scope.get_current_context();
92+
let global = context.global(scope);
93+
let key = v8::String::new(scope, class_name)?;
94+
let constructor = global.get(scope, key.into())?;
95+
if constructor.is_function() {
96+
let constructor = v8::Local::<v8::Function>::try_from(constructor).ok()?;
97+
let exception = constructor.new_instance(scope, args)?;
98+
Some(exception.into())
99+
} else {
100+
None
101+
}
102+
};
103+
104+
if let ExceptionClass::DomException(name) = class {
105+
if let Some(msg_val) = v8::String::new(scope, &err.exception_message()) {
106+
if let Some(name_val) = v8::String::new(scope, name) {
107+
if let Some(ex) = try_construct(scope, "DOMException", &[msg_val.into(), name_val.into()]) {
108+
return ex;
109+
}
110+
}
111+
}
112+
} else if let ExceptionClass::UriError = class {
113+
if let Some(msg_val) = v8::String::new(scope, &err.exception_message()) {
114+
if let Some(ex) = try_construct(scope, "URIError", &[msg_val.into()]) {
115+
return ex;
116+
}
117+
}
118+
}
119+
88120
let text = match class.dom_exception_name() {
89121
Some(name) => format!("{name}: {}", err.exception_message()),
90122
None => err.exception_message(),
@@ -95,8 +127,7 @@ pub(crate) fn build_exception<'s>(
95127
ExceptionClass::ReferenceError => v8::Exception::reference_error(scope, message),
96128
ExceptionClass::SyntaxError => v8::Exception::syntax_error(scope, message),
97129
ExceptionClass::TypeError => v8::Exception::type_error(scope, message),
98-
// `Error`, plus `URIError`/`DOMException` (no bare-V8 constructor) and
99-
// any future class: default to a plain `Error`.
130+
// `Error` and any future class: default to a plain `Error`.
100131
_ => v8::Exception::error(scope, message),
101132
}
102133
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Engine exception reconciliation tests.
2+
3+
test("engine-thrown DOMException is a true DOMException instance (NotSupportedError)", async () => {
4+
let caught = null;
5+
try {
6+
await crypto.subtle.digest("UNKNOWN", new Uint8Array());
7+
} catch (e) {
8+
caught = e;
9+
}
10+
11+
assertEquals(caught !== null, true);
12+
assertEquals(caught instanceof DOMException, true);
13+
assertEquals(caught.name, "NotSupportedError");
14+
assertEquals(caught.message.includes("unsupported digest"), true);
15+
});
16+
17+
test("engine-thrown DOMException with a different name (DataError)", async () => {
18+
let caught = null;
19+
try {
20+
// Importing invalid key material throws a DataError from the native op
21+
await crypto.subtle.importKey("spki", new Uint8Array([1, 2, 3]), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"]);
22+
} catch (e) {
23+
caught = e;
24+
}
25+
26+
assertEquals(caught !== null, true);
27+
assertEquals(caught instanceof DOMException, true);
28+
assertEquals(caught.name, "DataError");
29+
});
30+
31+
test("engine-thrown native TypeError is a true TypeError instance", async () => {
32+
let caught = null;
33+
try {
34+
// Passing a number where a string is expected
35+
await crypto.subtle.digest(123, new Uint8Array());
36+
} catch (e) {
37+
caught = e;
38+
}
39+
40+
assertEquals(caught !== null, true);
41+
assertEquals(caught instanceof TypeError, true);
42+
assertEquals(caught.name, "TypeError");
43+
});
44+
45+
test("engine-thrown DOMException with OperationError name", async () => {
46+
let caught = null;
47+
try {
48+
// AES-GCM decrypt with corrupted ciphertext throws OperationError
49+
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 128 }, false, ["encrypt", "decrypt"]);
50+
const iv = new Uint8Array(12);
51+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new Uint8Array([1, 2, 3]));
52+
// Corrupt the ciphertext tag
53+
const corrupted = new Uint8Array(ct);
54+
corrupted[corrupted.length - 1] ^= 1;
55+
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, corrupted);
56+
} catch (e) {
57+
caught = e;
58+
}
59+
60+
assertEquals(caught !== null, true);
61+
assertEquals(caught instanceof DOMException, true);
62+
assertEquals(caught.name, "OperationError");
63+
});

docs/DECISIONS.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,13 @@ Status: **Locked** · **Proposed** · **Open** (needs maintainer sign-off) · **
5858
> - **Engine trait now extracted** (resolving the Phase 1 "concrete only" note).
5959
> `engine::Engine` is object-safe and names no V8 type; `runtime` holds a
6060
> `Box<dyn Engine>`. The boundary held — no V8 type appears in `runtime`.
61-
> - **`DOMException` is partially real (updated Phase 4).** The prelude now
62-
> defines a real `globalThis.DOMException` class, so prelude APIs (atob/btoa,
63-
> structuredClone, Abort) throw the correct type with `instanceof Error`. The
64-
> remaining gap: errors thrown from the **engine** (Rust side, e.g. a capability
65-
> denial → `NotAllowedError`) still surface as a plain `Error` with a
66-
> name-prefixed message, because the engine has no handle to the JS class. A
67-
> later phase reconciles the two paths (engine throws the prelude's
68-
> `DOMException`).
61+
> - **`DOMException` is fully real.** The prelude defines a real
62+
> `globalThis.DOMException` class, so prelude APIs (atob/btoa,
63+
> structuredClone, Abort) throw the correct type with `instanceof Error`.
64+
> Errors thrown natively from the **engine** (Rust side, e.g. a capability
65+
> denial → `NotAllowedError`) now correctly resolve this class from
66+
> `globalThis` during construction, surfacing as true `DOMException` instances
67+
> to JS.
6968
> - **Async readiness is observed only on `tick`.** With no reactor (std-only,
7069
> `Waker::noop`), a pending op's future is polled when the embedder ticks, not
7170
> when its work actually becomes ready. *Reason:* the driven model (D4); a real

docs/SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ Productionizing the standalone runtime *and* stabilizing the embeddable API. ESM
140140

141141
**Deferrals:**
142142
- **Panic-across-FFI containment** (`catch_unwind` around op/timer/reject callbacks, per D12) — ☑ **implemented in Phase 9**: a host op panic is contained as a JS exception, not an abort (assumes `panic = "unwind"`). (DECISIONS D15.)
143-
- **`DOMException` engine reconciliation**the JS class exists (Phase 4 prelude), but errors thrown from the engine still surface as `Error` with a name-prefixed message. (DECISIONS D3a.)
143+
- **`DOMException` engine reconciliation****implemented**: the engine dynamically resolves `globalThis.DOMException` when marshaling a native `DOMException`, surfacing it as a proper instance of the JS class (resolves DECISIONS D3a).
144144
- **Byte/BYOB streams** (`ReadableByteStreamController`, BYOB readers) — ☑ **implemented in Phase 9** (copy-based, no ArrayBuffer transfer/detach; DECISIONS D19). Default streams + encoding streams shipped in Phase 5.
145145
- **Streaming `fetch` request bodies** → a follow-up; Phase 6 buffers the request body and streams the response (DECISIONS D20).
146146
- **`crypto.subtle` minor gaps.** The algorithm set is complete (digest/HMAC/AES-GCM/CBC/CTR, HKDF/PBKDF2, ECDSA/ECDH, RSA PKCS1-v1_5/PSS/OAEP — DECISIONS D9). Remaining edges: AES-CTR supports only 32/64/128-bit counter widths (others → `NotSupportedError`); RSA-OAEP **labels must be UTF-8** (the `rsa` 0.9 API limitation; non-UTF-8 → `NotSupportedError`); EC keys import/export as raw/spki/pkcs8/jwk and RSA as spki/pkcs8/jwk; `deriveKey` targets AES-* and HMAC keys. All asymmetric signing/keygen randomness routes through the Entropy provider, never ambient `OsRng`. RSA carries an **accepted timing-sidechannel advisory** (RUSTSEC-2023-0071) tracked on the SECURITY.md revisit list.

site/app/docs/security/page.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export default function SecurityDoc() {
4343
<p className="mt-3 text-zinc-600">
4444
Every host operation declares the capability it requires. The check
4545
lives on the native op, not in JavaScript, so it cannot be bypassed by
46-
reaching a different module path.
46+
reaching a different module path. A denied capability will instantly
47+
throw a standard <code>DOMException</code> with the <code>NotAllowedError</code> name.
4748
</p>
4849
<div className="mt-5 overflow-hidden rounded-xl border border-zinc-200">
4950
<table className="w-full text-left text-sm">

0 commit comments

Comments
 (0)