diff --git a/src/hyperlight-js-runtime/src/lib.rs b/src/hyperlight-js-runtime/src/lib.rs index 4d89e2e..c4ede01 100644 --- a/src/hyperlight-js-runtime/src/lib.rs +++ b/src/hyperlight-js-runtime/src/lib.rs @@ -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. ''), 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 @@ -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; diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 09cf683..45622e8 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -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 = 'value'; + 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":"value"}"# + ); + } + + #[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"}"#); + } }