Skip to content

Commit bb1d0dd

Browse files
committed
feat: allow builtin module overrides, extensible globals, and freeze
- Allow overriding built-in modules (io, crypto, console) via native_modules! — custom modules take priority over built-ins. The require module is protected and cannot be overridden. - Make console and print globals writable during init so custom_globals! can extend them (e.g. add console.warn/error). - Freeze console (Object.freeze) and print (non-writable) after custom_globals! runs — handler code cannot tamper with them. - 3-step init in JsRuntime::new: setup → custom_globals → freeze. - New globals/freeze.rs module for post-init lockdown. - Tests: require override rejection, console extension via custom_globals, freeze verification, console.log after freeze. - Updated docs/extending-runtime.md with override rules. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 02e97e5 commit bb1d0dd

File tree

8 files changed

+229
-29
lines changed

8 files changed

+229
-29
lines changed

docs/extending-runtime.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,13 @@ modules into the global native module registry. Called automatically by the
189189
`NativeModuleLoader` on first use — you never need to call it yourself.
190190
Built-in modules are inherited automatically.
191191

192-
**Restrictions:**
193-
- Custom module names **cannot** shadow built-in modules (`io`, `crypto`,
194-
`console`, `require`). Attempting to register a built-in name panics.
192+
Custom modules with the same name as a built-in (`io`, `crypto`, `console`)
193+
take priority, allowing extender crates to replace built-in implementations
194+
when needed.
195+
196+
**Restriction:** The `require` module cannot be overridden — it is part of
197+
the runtime's core module loading infrastructure. Attempting to register
198+
a module named `"require"` will panic.
195199

196200
### `register_native_module`
197201

src/hyperlight-js-runtime/src/globals/console.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
16-
use rquickjs::object::Property;
17-
use rquickjs::{Ctx, Module, Object};
16+
use rquickjs::{Ctx, Function, Module, Object};
1817

1918
pub fn setup(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
2019
let globals = ctx.globals();
2120

22-
// Setup `console`.
23-
let console: Object = Module::import(ctx, "console")?.finish()?;
24-
globals.prop("console", Property::from(console))?;
21+
// Create console as a plain extensible Object (not the frozen module namespace).
22+
// This allows custom_globals! consumers to add methods (warn, error, info, debug)
23+
// before globals::freeze() locks it down.
24+
let console_mod: Object = Module::import(ctx, "console")?.finish()?;
25+
let console = Object::new(ctx.clone())?;
26+
console.set("log", console_mod.get::<_, Function>("log")?)?;
27+
globals.set("console", console)?;
2528

2629
Ok(())
2730
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
Copyright 2026 The Hyperlight Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
use rquickjs::Ctx;
17+
18+
/// Freeze built-in globals so handler code cannot tamper with them.
19+
///
20+
/// Called AFTER custom_globals! so extender crates can modify/extend
21+
/// globals first (e.g. adding console.warn/error/info/debug).
22+
///
23+
/// Frozen: console (Object.freeze), print (non-writable).
24+
/// Already frozen: require (non-configurable from setup),
25+
/// String.bytesFrom (on frozen String constructor).
26+
pub fn setup(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
27+
ctx.eval::<(), _>(
28+
r#"
29+
if (typeof globalThis.console === 'object') {
30+
Object.freeze(globalThis.console);
31+
}
32+
if (typeof globalThis.print === 'function') {
33+
Object.defineProperty(globalThis, 'print', {
34+
writable: false,
35+
configurable: false
36+
});
37+
}
38+
"#,
39+
)?;
40+
Ok(())
41+
}

src/hyperlight-js-runtime/src/globals/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,22 @@ limitations under the License.
1616
use rquickjs::Ctx;
1717

1818
mod console;
19+
mod freeze;
1920
mod print;
2021
mod require;
2122
mod string;
2223

24+
/// Setup built-in globals (writable — before custom_globals!).
2325
pub fn setup(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
2426
string::setup(ctx)?;
2527
print::setup(ctx)?;
2628
console::setup(ctx)?;
2729
require::setup(ctx)?;
2830
Ok(())
2931
}
32+
33+
/// Freeze built-in globals (after custom_globals! has run).
34+
pub fn freeze(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
35+
freeze::setup(ctx)?;
36+
Ok(())
37+
}

src/hyperlight-js-runtime/src/globals/print.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,16 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
16-
use rquickjs::object::Property;
1716
use rquickjs::{Ctx, Function, Module, Object};
1817

1918
pub fn setup(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
2019
let globals = ctx.globals();
2120

22-
// Setup `print` function.
21+
// Setup `print` as a writable global.
22+
// Allows custom_globals! to override (e.g. for output capture).
23+
// Frozen by globals::freeze() after custom_globals! runs.
2324
let io: Object = Module::import(ctx, "io")?.finish()?;
24-
globals.prop("print", Property::from(io.get::<_, Function>("print")?))?;
25+
globals.set("print", io.get::<_, Function>("print")?)?;
2526

2627
Ok(())
2728
}

src/hyperlight-js-runtime/src/lib.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,16 @@ impl JsRuntime {
102102
// store some global state needed for module instantiation.
103103
host_loader.install(&ctx)?;
104104

105-
// Setup the global objects in the context, so they are available to the handler scripts.
105+
// Step 1: Setup the global objects in the context, so they are available to the handler scripts.
106106
globals::setup(&ctx).catch(&ctx)?;
107107

108-
// Setup custom globals registered by extender crates via custom_globals! macro.
108+
// Step 2: Setup custom globals registered by extender crates via custom_globals! macro.
109109
// Runs after built-in globals so custom setup can reference console, require, etc.
110-
modules::setup_custom_globals(&ctx).catch(&ctx)
110+
// also allows custom_globals! to override some (io,console) built-in globals if needed (e.g. for testing).
111+
modules::setup_custom_globals(&ctx).catch(&ctx)?;
112+
113+
// Step 3: Freeze built-in globals (handler code can't tamper)
114+
globals::freeze(&ctx).catch(&ctx)
111115
})?;
112116

113117
Ok(Self {

src/hyperlight-js-runtime/src/modules/mod.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,19 +67,22 @@ static CUSTOM_MODULES: Lazy<Mutex<HashMap<&'static str, ModuleDeclarationFn>>> =
6767
/// Register a custom native module by name.
6868
///
6969
/// The module will be available to JavaScript via `import { ... } from "name"`.
70-
/// Custom modules cannot shadow built-in modules (io, crypto, console, require).
70+
/// Custom modules take priority over built-in modules with the same name,
71+
/// allowing extender crates to replace built-ins (e.g. `io`, `crypto`,
72+
/// `console`) with custom implementations.
73+
///
74+
/// The `require` module cannot be overridden — it is part of the runtime's
75+
/// core module loading infrastructure.
7176
///
7277
/// This is typically called via the [`native_modules!`] macro rather than
7378
/// directly.
7479
///
7580
/// # Panics
7681
///
77-
/// Panics if `name` collides with a built-in module name.
82+
/// Panics if `name` is `"require"`.
7883
pub fn register_native_module(name: &'static str, decl: ModuleDeclarationFn) {
79-
if BUILTIN_MODULES.contains_key(name) {
80-
panic!(
81-
"Cannot register custom native module '{name}': name conflicts with a built-in module"
82-
);
84+
if name == "require" {
85+
panic!("Cannot override the 'require' module — it is part of the runtime's core infrastructure");
8386
}
8487
CUSTOM_MODULES.lock().insert(name, decl);
8588
}
@@ -152,8 +155,8 @@ impl Loader for NativeModuleLoader {
152155
/// }
153156
/// ```
154157
///
155-
/// Custom module names **cannot** shadow built-in modules (`io`, `crypto`,
156-
/// `console`, `require`). Attempting to do so will panic at startup.
158+
/// Custom modules take priority over built-in modules with the same name,
159+
/// allowing extender crates to replace built-ins with custom implementations.
157160
#[macro_export]
158161
macro_rules! native_modules {
159162
($($name:expr => $module:ty),* $(,)?) => {

src/hyperlight-js-runtime/tests/native_modules.rs

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,23 +153,29 @@ fn builtins_still_work_after_custom_registration() {
153153
});
154154
}
155155

156-
// ── Override prevention ────────────────────────────────────────────────────
156+
// ── Built-in override ──────────────────────────────────────────────────────
157157

158158
#[test]
159-
#[should_panic(expected = "conflicts with a built-in module")]
160-
fn registering_builtin_name_panics() {
159+
#[should_panic(expected = "Cannot override the 'require' module")]
160+
fn overriding_require_panics() {
161161
#[rquickjs::module(rename_vars = "camelCase")]
162-
mod fake_io {
162+
mod fake_require {
163163
#[rquickjs::function]
164-
pub fn print(_txt: String) {}
164+
pub fn require(_name: String) -> String {
165+
String::from("fake")
166+
}
165167
}
166168

167169
hyperlight_js_runtime::modules::register_native_module(
168-
"io",
169-
hyperlight_js_runtime::modules::declaration::<js_fake_io>(),
170+
"require",
171+
hyperlight_js_runtime::modules::declaration::<js_fake_require>(),
170172
);
171173
}
172174

175+
// Note: overriding io/crypto/console is allowed but tested via the
176+
// full-pipeline extended_runtime fixture to avoid poisoning the shared
177+
// static module registry used by other tests in this process.
178+
173179
// ── native_modules! macro ──────────────────────────────────────────────────
174180

175181
#[rquickjs::module(rename_vars = "camelCase")]
@@ -192,9 +198,20 @@ fn setup_test_constant(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
192198
Ok(())
193199
}
194200

201+
fn setup_console_extensions(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
202+
ctx.eval::<(), _>(
203+
r#"
204+
console.warn = console.log;
205+
console.error = console.log;
206+
"#,
207+
)?;
208+
Ok(())
209+
}
210+
195211
// The macro generates init_custom_globals() which calls each setup function
196212
hyperlight_js_runtime::custom_globals! {
197213
setup_test_constant,
214+
setup_console_extensions,
198215
}
199216

200217
#[test]
@@ -460,6 +477,26 @@ fn full_pipeline_console_log_with_custom_modules() {
460477

461478
// ── custom_globals! tests ──────────────────────────────────────────────────
462479

480+
#[test]
481+
fn e2e_console_warn_works_via_custom_globals() {
482+
let mut runtime =
483+
hyperlight_js_runtime::JsRuntime::new(NoOpHost).expect("Failed to create JsRuntime");
484+
485+
let handler = r#"
486+
export function handler() {
487+
console.warn("warn test");
488+
console.error("error test");
489+
return "ok";
490+
}
491+
"#;
492+
493+
runtime.register_handler("warn_test", handler, ".").unwrap();
494+
let result = runtime
495+
.run_handler("warn_test".into(), "{}".into(), false)
496+
.unwrap();
497+
assert_eq!(result, "\"ok\"");
498+
}
499+
463500
#[test]
464501
fn e2e_custom_globals_are_available_in_handlers() {
465502
let mut runtime =
@@ -574,3 +611,102 @@ fn full_pipeline_custom_globals_with_modules() {
574611
"Expected 42 + 8 = 50, got: {stdout}"
575612
);
576613
}
614+
// ── globals freeze tests ───────────────────────────────────────────────────
615+
616+
#[test]
617+
fn e2e_console_is_extensible_during_custom_globals() {
618+
// setup_test_constant already runs via custom_globals! in this file.
619+
// Verify custom globals constant works (proves custom_globals! ran).
620+
let mut runtime =
621+
hyperlight_js_runtime::JsRuntime::new(NoOpHost).expect("Failed to create JsRuntime");
622+
623+
let handler = r#"
624+
export function handler() {
625+
return TEST_CUSTOM_GLOBAL;
626+
}
627+
"#;
628+
629+
runtime
630+
.register_handler("extensible_test", handler, ".")
631+
.unwrap();
632+
633+
let result = runtime
634+
.run_handler("extensible_test".into(), "{}".into(), false)
635+
.unwrap();
636+
assert_eq!(result, "99");
637+
}
638+
639+
#[test]
640+
fn e2e_console_frozen_after_init() {
641+
let mut runtime =
642+
hyperlight_js_runtime::JsRuntime::new(NoOpHost).expect("Failed to create JsRuntime");
643+
644+
let handler = r#"
645+
export function handler() {
646+
try {
647+
console.custom = function() {};
648+
return "not_frozen";
649+
} catch(e) {
650+
return "frozen";
651+
}
652+
}
653+
"#;
654+
655+
runtime
656+
.register_handler("freeze_test", handler, ".")
657+
.unwrap();
658+
let result = runtime
659+
.run_handler("freeze_test".into(), "{}".into(), false)
660+
.unwrap();
661+
assert!(
662+
result.contains("frozen"),
663+
"console should be frozen, got: {result}"
664+
);
665+
}
666+
667+
#[test]
668+
fn e2e_print_frozen_after_init() {
669+
let mut runtime =
670+
hyperlight_js_runtime::JsRuntime::new(NoOpHost).expect("Failed to create JsRuntime");
671+
672+
let handler = r#"
673+
export function handler() {
674+
try {
675+
globalThis.print = function() {};
676+
return "not_frozen";
677+
} catch(e) {
678+
return "frozen";
679+
}
680+
}
681+
"#;
682+
683+
runtime
684+
.register_handler("print_freeze", handler, ".")
685+
.unwrap();
686+
let result = runtime
687+
.run_handler("print_freeze".into(), "{}".into(), false)
688+
.unwrap();
689+
assert!(
690+
result.contains("frozen"),
691+
"print should be frozen, got: {result}"
692+
);
693+
}
694+
695+
#[test]
696+
fn e2e_console_log_still_works_after_freeze() {
697+
let mut runtime =
698+
hyperlight_js_runtime::JsRuntime::new(NoOpHost).expect("Failed to create JsRuntime");
699+
700+
let handler = r#"
701+
export function handler() {
702+
console.log("still works");
703+
return "ok";
704+
}
705+
"#;
706+
707+
runtime.register_handler("log_test", handler, ".").unwrap();
708+
let result = runtime
709+
.run_handler("log_test".into(), "{}".into(), false)
710+
.unwrap();
711+
assert_eq!(result, "\"ok\"");
712+
}

0 commit comments

Comments
 (0)