Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/hyperlight-js-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,15 @@ impl JsRuntime {
let handler_script = handler_script.into();
let handler_pwd = handler_pwd.into();

// If the handler script doesn't already export the handler function, we export it for the user.
// This is a convenience for the common case where the handler script is just a single file that defines
// the handler function, without needing to explicitly export it.
let handler_script = if !handler_script.contains("export") {
// If the handler script doesn't already contain an ES export statement,
// append one for the user. This is a convenience for the common case where
// the handler script defines a handler function without explicitly exporting it.
//
// We check whether any line *starts* with `export` (after leading whitespace)
// rather than using a naive `.contains("export")`, which would false-positive
// on string literals (e.g. '<config mode="export">'), comments
// (e.g. // TODO: export data), or identifiers (e.g. exportPath).
let handler_script = if !has_export_statement(&handler_script) {
format!("{}\nexport {{ handler }};", handler_script)
} else {
handler_script
Expand Down Expand Up @@ -315,6 +320,20 @@ fn make_handler_path(function_name: &str, handler_dir: &str) -> String {
handler_path
}

/// Returns `true` if the script contains an actual ES `export` statement
/// (as opposed to the word "export" inside a string literal, comment, or
/// identifier like `exportPath`).
///
/// The heuristic checks whether any source line begins with `export` (after
/// optional leading whitespace). This avoids the false positives from a
/// naive `.contains("export")` while staying `no_std`-compatible.
fn has_export_statement(script: &str) -> bool {
script.lines().any(|line| {
let trimmed = line.trim_start();
trimmed.starts_with("export ") || trimmed.starts_with("export{")
})
}

// RAII guard that flushes the output buffer of libc when dropped.
// This is used to make sure we flush all output after running a handler, without needing to manually call it in every code path.
struct FlushGuard;
Expand Down
121 changes: 121 additions & 0 deletions src/hyperlight-js/src/sandbox/js_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,125 @@ mod tests {
let res = sandbox.get_loaded_sandbox();
assert!(res.is_ok());
}

// ── Auto-export heuristic tests (issue #39) ──────────────────────────
// The auto-export logic must only detect actual ES export statements,
// not the word "export" inside string literals, comments, or identifiers.

#[test]
fn handler_with_export_in_string_literal() {
// "export" appears inside a string — auto-export should still fire
let handler = Script::from_content(
r#"
function handler(event) {
const xml = '<config mode="export">value</config>';
return { result: xml };
}
"#,
);

let proto = SandboxBuilder::new().build().unwrap();
let mut sandbox = proto.load_runtime().unwrap();
sandbox.add_handler("handler", handler).unwrap();
let mut loaded = sandbox.get_loaded_sandbox().unwrap();

let res = loaded
.handle_event("handler", "{}".to_string(), None)
.unwrap();
assert_eq!(
res,
r#"{"result":"<config mode=\"export\">value</config>"}"#
);
}

#[test]
fn handler_with_export_in_comment() {
// "export" appears in a comment — auto-export should still fire
let handler = Script::from_content(
r#"
function handler(event) {
// TODO: export this data to CSV
return { result: 42 };
}
"#,
);

let proto = SandboxBuilder::new().build().unwrap();
let mut sandbox = proto.load_runtime().unwrap();
sandbox.add_handler("handler", handler).unwrap();
let mut loaded = sandbox.get_loaded_sandbox().unwrap();

let res = loaded
.handle_event("handler", "{}".to_string(), None)
.unwrap();
assert_eq!(res, r#"{"result":42}"#);
}

#[test]
fn handler_with_export_in_identifier() {
// "export" is part of an identifier — auto-export should still fire
let handler = Script::from_content(
r#"
function handler(event) {
const exportPath = "/tmp/out.csv";
return { result: exportPath };
}
"#,
);

let proto = SandboxBuilder::new().build().unwrap();
let mut sandbox = proto.load_runtime().unwrap();
sandbox.add_handler("handler", handler).unwrap();
let mut loaded = sandbox.get_loaded_sandbox().unwrap();

let res = loaded
.handle_event("handler", "{}".to_string(), None)
.unwrap();
assert_eq!(res, r#"{"result":"/tmp/out.csv"}"#);
}

#[test]
fn handler_with_explicit_export_is_not_doubled() {
// Script already has an export statement — auto-export should be skipped
let handler = Script::from_content(
r#"
function handler(event) {
return { result: "explicit" };
}
export { handler };
"#,
);

let proto = SandboxBuilder::new().build().unwrap();
let mut sandbox = proto.load_runtime().unwrap();
sandbox.add_handler("handler", handler).unwrap();
let mut loaded = sandbox.get_loaded_sandbox().unwrap();

let res = loaded
.handle_event("handler", "{}".to_string(), None)
.unwrap();
assert_eq!(res, r#"{"result":"explicit"}"#);
}

#[test]
fn handler_with_export_default_function() {
// `export function` — auto-export should be skipped
let handler = Script::from_content(
r#"
export function handler(event) {
return { result: "inline-export" };
}
"#,
);

let proto = SandboxBuilder::new().build().unwrap();
let mut sandbox = proto.load_runtime().unwrap();
sandbox.add_handler("handler", handler).unwrap();
let mut loaded = sandbox.get_loaded_sandbox().unwrap();

let res = loaded
.handle_event("handler", "{}".to_string(), None)
.unwrap();
assert_eq!(res, r#"{"result":"inline-export"}"#);
}
}
Loading