wafer-web: add set_prompter for a JS-backed READ-PASSWORD
Browser consumers (kelvar) need a host-provided password prompt so the
master never appears on the command line. Exposes a single method:
WaferRepl::set_prompter(js_sys::Function) -> Result<(), JsError>
Given a JS function `(prompt: string) => string`, registers it as the
Forth word `READ-PASSWORD` with stack effect
( prompt-addr prompt-u -- pw-addr pw-u )
The returned bytes land in WAFER's PAD region. Enforces PAD_SIZE-1 as
a hard upper bound — a silent truncation would cause a derived password
to mismatch the one used during setup, which is exactly the failure
mode we are trying to avoid.
`js_sys::Function` is !Send/!Sync but `HostFn` requires both. In a
browser WASM build there is only ever one thread, so wrap it in
`send_wrapper::SendWrapper`, which panics if accessed off-thread — an
honest guard rather than a lie.
This commit is contained in:
Generated
+8
@@ -1410,6 +1410,12 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "send_wrapper"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -1771,9 +1777,11 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"js-sys",
|
||||
"send_wrapper",
|
||||
"wafer-core",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
"wasm-encoder 0.246.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -50,3 +50,4 @@ proptest = "1"
|
||||
insta = "1"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
send_wrapper = "0.6"
|
||||
|
||||
@@ -15,6 +15,8 @@ crate-type = ["cdylib", "rlib"]
|
||||
wafer-core = { path = "../core", version = "0.1.0", default-features = false, features = ["crypto"] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
send_wrapper = { workspace = true }
|
||||
wasm-encoder = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+80
-2
@@ -2,9 +2,13 @@
|
||||
|
||||
mod runtime_web;
|
||||
|
||||
use send_wrapper::SendWrapper;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use wafer_core::config::WaferConfig;
|
||||
use wafer_core::memory::{CELL_SIZE, PAD_BASE, PAD_SIZE};
|
||||
use wafer_core::outer::ForthVM;
|
||||
use wafer_core::runtime::{HostAccess, HostFn};
|
||||
|
||||
use crate::runtime_web::WebRuntime;
|
||||
|
||||
@@ -19,7 +23,13 @@ impl WaferRepl {
|
||||
/// Create a new WAFER REPL instance with all built-in words.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Result<WaferRepl, JsError> {
|
||||
let vm = ForthVM::<WebRuntime>::new().map_err(|e| JsError::new(&e.to_string()))?;
|
||||
// Disable stack-to-local promotion: it currently mis-models host-
|
||||
// function calls in the web runtime, leaving a ghost copy of the
|
||||
// pre-call args on the Forth data stack after the host word returns.
|
||||
let mut cfg = WaferConfig::all();
|
||||
cfg.codegen.stack_to_local_promotion = false;
|
||||
let vm = ForthVM::<WebRuntime>::new_with_config(cfg)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(WaferRepl { vm })
|
||||
}
|
||||
|
||||
@@ -50,7 +60,75 @@ impl WaferRepl {
|
||||
|
||||
/// Reset the VM to initial state.
|
||||
pub fn reset(&mut self) -> Result<(), JsError> {
|
||||
self.vm = ForthVM::<WebRuntime>::new().map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let mut cfg = WaferConfig::all();
|
||||
cfg.codegen.stack_to_local_promotion = false;
|
||||
self.vm = ForthVM::<WebRuntime>::new_with_config(cfg)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a JavaScript function as a Forth word with stack effect
|
||||
/// `( prompt-a prompt-u -- pw-a pw-u )`.
|
||||
///
|
||||
/// The JS function receives one argument — the prompt string — and must
|
||||
/// return the password as a string (synchronously; `window.prompt` is a
|
||||
/// reasonable baseline, a masked DOM overlay is a strict improvement).
|
||||
/// The returned bytes are written into WAFER's `PAD` region; callers
|
||||
/// must consume them before invoking any other word that also writes
|
||||
/// to `PAD`.
|
||||
///
|
||||
/// Registering under a dedicated name (e.g. `"JS-PROMPT"`) and then
|
||||
/// retargeting an existing DEFER with `' JS-PROMPT IS READ-PASSWORD`
|
||||
/// is the usual pattern — it lets late-binding downstream words like
|
||||
/// kelvar's `PASS` pick up the host implementation without recompiling.
|
||||
pub fn set_prompter(&mut self, name: &str, js_fn: js_sys::Function) -> Result<(), JsError> {
|
||||
let holder = SendWrapper::new(js_fn);
|
||||
let max = (PAD_SIZE - 1) as usize;
|
||||
|
||||
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
|
||||
// Pop ( prompt-a prompt-u ): advance dsp by 2 cells.
|
||||
let mut sp = ctx.get_dsp();
|
||||
let u = ctx.mem_read_i32(sp) as u32;
|
||||
sp += CELL_SIZE;
|
||||
let a = ctx.mem_read_i32(sp) as u32;
|
||||
sp += CELL_SIZE;
|
||||
ctx.set_dsp(sp);
|
||||
|
||||
let prompt_bytes = ctx.mem_read_slice(a, u as usize);
|
||||
let prompt = String::from_utf8_lossy(&prompt_bytes).to_string();
|
||||
|
||||
let result = holder
|
||||
.call1(&JsValue::NULL, &JsValue::from_str(&prompt))
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"prompter threw: {}",
|
||||
e.as_string().unwrap_or_else(|| "<non-string>".into())
|
||||
)
|
||||
})?;
|
||||
let pw = result.as_string().unwrap_or_default();
|
||||
|
||||
let bytes = pw.as_bytes();
|
||||
if bytes.len() > max {
|
||||
anyhow::bail!(
|
||||
"READ-PASSWORD: master too long ({} > {} bytes)",
|
||||
bytes.len(),
|
||||
max
|
||||
);
|
||||
}
|
||||
|
||||
ctx.mem_write_slice(PAD_BASE, bytes);
|
||||
// Push ( PAD bytes.len() )
|
||||
sp -= CELL_SIZE;
|
||||
ctx.mem_write_i32(sp, PAD_BASE as i32);
|
||||
sp -= CELL_SIZE;
|
||||
ctx.mem_write_i32(sp, bytes.len() as i32);
|
||||
ctx.set_dsp(sp);
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.vm
|
||||
.register_host_primitive(name, false, func)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(crate) struct WebRuntime {
|
||||
emit_func: JsValue,
|
||||
#[allow(dead_code)]
|
||||
output: Arc<Mutex<String>>,
|
||||
/// Keep closures alive to prevent GC.
|
||||
/// Keep closures and wrapper-module Instances alive to prevent GC.
|
||||
_closures: Vec<JsValue>,
|
||||
}
|
||||
|
||||
@@ -209,9 +209,12 @@ impl Runtime for WebRuntime {
|
||||
out_ref.lock().unwrap().push(ch);
|
||||
}) as Box<dyn FnMut(i32)>);
|
||||
|
||||
// Use WebAssembly.Function if available, else wrap in a tiny module
|
||||
let emit_func = make_wasm_function_i32(&emit_closure.as_ref().into());
|
||||
let mut closures = vec![emit_closure.into_js_value()];
|
||||
// Use WebAssembly.Function if available, else wrap in a tiny module.
|
||||
// The wrapper-module path also returns the Instance so we can keep it
|
||||
// alive for the lifetime of the runtime (browsers can otherwise GC
|
||||
// the instance and orphan the funcref's body).
|
||||
let (emit_func, emit_keep) = make_wasm_function_i32(&emit_closure.as_ref().into());
|
||||
let mut closures = vec![emit_closure.into_js_value(), emit_keep];
|
||||
let _ = &mut closures; // keep alive
|
||||
|
||||
Ok(WebRuntime {
|
||||
@@ -432,7 +435,7 @@ impl Runtime for WebRuntime {
|
||||
}
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
let wasm_func = make_wasm_function_void(&closure.as_ref().into());
|
||||
let (wasm_func, keep_alive) = make_wasm_function_void(&closure.as_ref().into());
|
||||
|
||||
let set_fn: Function = Reflect::get(&self.table, &"set".into())
|
||||
.unwrap()
|
||||
@@ -441,14 +444,24 @@ impl Runtime for WebRuntime {
|
||||
.call2(&self.table, &JsValue::from(fn_index), &wasm_func)
|
||||
.map_err(|e| anyhow::anyhow!("table.set({fn_index}) failed: {e:?}"))?;
|
||||
|
||||
// Stash both the closure AND the wrapper-module instance so neither
|
||||
// is garbage-collected while the funcref is still in WAFER's table.
|
||||
// Without keeping the Instance alive, browsers can orphan the funcref's
|
||||
// body and cross-module `call_indirect` silently fails to dispatch.
|
||||
self._closures.push(closure.into_js_value());
|
||||
self._closures.push(keep_alive);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a `WebAssembly.Function({parameters:['i32'],results:[]}, jsFn)`.
|
||||
/// Falls back to a wrapper module if `WebAssembly.Function` is unavailable.
|
||||
fn make_wasm_function_i32(js_fn: &JsValue) -> JsValue {
|
||||
/// Create a wasm-callable funcref of type `(i32) -> ()` from a JS callback.
|
||||
///
|
||||
/// Returns `(funcref, keep_alive)`. Callers MUST stash `keep_alive` somewhere
|
||||
/// the GC can see — for the wrapper-module path it's the underlying
|
||||
/// `WebAssembly.Instance`, and the funcref is only valid as long as the
|
||||
/// instance lives. The `WebAssembly.Function` constructor path returns
|
||||
/// `JsValue::NULL` for `keep_alive`.
|
||||
fn make_wasm_function_i32(js_fn: &JsValue) -> (JsValue, JsValue) {
|
||||
if let Ok(wasm_func_ctor) = get_wasm_function_ctor() {
|
||||
let desc = Object::new();
|
||||
let params = js_sys::Array::new();
|
||||
@@ -458,15 +471,15 @@ fn make_wasm_function_i32(js_fn: &JsValue) -> JsValue {
|
||||
let args = js_sys::Array::new();
|
||||
args.push(&desc);
|
||||
args.push(js_fn);
|
||||
Reflect::construct(&wasm_func_ctor.unchecked_into::<Function>(), &args).unwrap()
|
||||
let f = Reflect::construct(&wasm_func_ctor.unchecked_into::<Function>(), &args).unwrap();
|
||||
(f, JsValue::NULL)
|
||||
} else {
|
||||
// Fallback: create a tiny WASM module that wraps the JS function
|
||||
make_wrapper_module_i32(js_fn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a `WebAssembly.Function({parameters:[],results:[]}, jsFn)`.
|
||||
fn make_wasm_function_void(js_fn: &JsValue) -> JsValue {
|
||||
/// Create a wasm-callable funcref of type `() -> ()`. See [`make_wasm_function_i32`].
|
||||
fn make_wasm_function_void(js_fn: &JsValue) -> (JsValue, JsValue) {
|
||||
if let Ok(wasm_func_ctor) = get_wasm_function_ctor() {
|
||||
let desc = Object::new();
|
||||
Reflect::set(&desc, &"parameters".into(), &js_sys::Array::new()).unwrap();
|
||||
@@ -474,7 +487,8 @@ fn make_wasm_function_void(js_fn: &JsValue) -> JsValue {
|
||||
let args = js_sys::Array::new();
|
||||
args.push(&desc);
|
||||
args.push(js_fn);
|
||||
Reflect::construct(&wasm_func_ctor.unchecked_into::<Function>(), &args).unwrap()
|
||||
let f = Reflect::construct(&wasm_func_ctor.unchecked_into::<Function>(), &args).unwrap();
|
||||
(f, JsValue::NULL)
|
||||
} else {
|
||||
make_wrapper_module_void(js_fn)
|
||||
}
|
||||
@@ -491,45 +505,66 @@ fn get_wasm_function_ctor() -> Result<Function, ()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback: create a minimal WASM module that imports and re-exports a void→void function.
|
||||
fn make_wrapper_module_void(js_fn: &JsValue) -> JsValue {
|
||||
// (module (import "e" "f" (func)) (export "f" (func 0)))
|
||||
/// Fallback: create a minimal WASM module that imports a JS function and
|
||||
/// exports a **local** trampoline which calls it.
|
||||
///
|
||||
/// Exporting the imported funcref directly (the previous approach) produces a
|
||||
/// funcref that works when invoked via JS `.call()`, but v8 and other engines
|
||||
/// do not always dispatch to the underlying JS body when that funcref is
|
||||
/// stored in a different module's table and invoked via `call_indirect`. A
|
||||
/// local trampoline (a real WASM function that `call`s the import) gives a
|
||||
/// stable, spec-correct funcref usable from any caller.
|
||||
fn make_wrapper_module_void(js_fn: &JsValue) -> (JsValue, JsValue) {
|
||||
// (module
|
||||
// (import "e" "f" (func $imp)) ;; func 0
|
||||
// (func (export "f") (call $imp)) ;; func 1
|
||||
// )
|
||||
#[rustfmt::skip]
|
||||
let bytes: &[u8] = &[
|
||||
0x00, 0x61, 0x73, 0x6d, // magic
|
||||
0x01, 0x00, 0x00, 0x00, // version
|
||||
// type section: 1 type, () -> ()
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version
|
||||
// type: 1 type, () -> ()
|
||||
0x01, 0x04, 0x01, 0x60, 0x00, 0x00,
|
||||
// import section: import "e" "f" func type 0
|
||||
// import: "e"."f" func type 0 -> imported func index 0
|
||||
0x02, 0x07, 0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00,
|
||||
// export section: export "f" func 0
|
||||
0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00,
|
||||
// function: 1 local func of type 0 -> local func index 1
|
||||
0x03, 0x02, 0x01, 0x00,
|
||||
// export: "f" -> func index 1 (the local trampoline)
|
||||
0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x01,
|
||||
// code: 1 body, 4 bytes, 0 locals, `call 0`, `end`
|
||||
0x0a, 0x06, 0x01, 0x04, 0x00, 0x10, 0x00, 0x0b,
|
||||
];
|
||||
let u8arr = Uint8Array::from(bytes);
|
||||
let module = WebAssembly::Module::new(&u8arr.into()).unwrap();
|
||||
let env = Object::new();
|
||||
Reflect::set(&env, &"f".into(), js_fn).unwrap();
|
||||
let imports = Object::new();
|
||||
Reflect::set(&imports, &"e".into(), &env).unwrap();
|
||||
let instance = WebAssembly::Instance::new(&module, &imports).unwrap();
|
||||
let exports = Reflect::get(&instance, &"exports".into()).unwrap();
|
||||
Reflect::get(&exports, &"f".into()).unwrap()
|
||||
instantiate_wrapper(bytes, js_fn)
|
||||
}
|
||||
|
||||
/// Fallback: create a minimal WASM module that imports and re-exports an (i32)→() function.
|
||||
fn make_wrapper_module_i32(js_fn: &JsValue) -> JsValue {
|
||||
// (module (import "e" "f" (func (param i32))) (export "f" (func 0)))
|
||||
/// Fallback: same idea as [`make_wrapper_module_void`] but for `(i32) -> ()`.
|
||||
/// The trampoline forwards its one parameter to the import.
|
||||
fn make_wrapper_module_i32(js_fn: &JsValue) -> (JsValue, JsValue) {
|
||||
// (module
|
||||
// (import "e" "f" (func $imp (param i32)))
|
||||
// (func (export "f") (param i32) (local.get 0) (call $imp))
|
||||
// )
|
||||
#[rustfmt::skip]
|
||||
let bytes: &[u8] = &[
|
||||
0x00, 0x61, 0x73, 0x6d,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
// type section: 1 type, (i32) -> ()
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
// type: 1 type, (i32) -> ()
|
||||
0x01, 0x05, 0x01, 0x60, 0x01, 0x7f, 0x00,
|
||||
// import section: import "e" "f" func type 0
|
||||
// import: "e"."f" func type 0
|
||||
0x02, 0x07, 0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00,
|
||||
// export section: export "f" func 0
|
||||
0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00,
|
||||
// function: 1 local func of type 0
|
||||
0x03, 0x02, 0x01, 0x00,
|
||||
// export: "f" -> func index 1
|
||||
0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x01,
|
||||
// code: 1 body, 6 bytes, 0 locals, `local.get 0`, `call 0`, `end`
|
||||
0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x10, 0x00, 0x0b,
|
||||
];
|
||||
instantiate_wrapper(bytes, js_fn)
|
||||
}
|
||||
|
||||
/// Shared: compile the wrapper module, instantiate with `js_fn` bound to
|
||||
/// import `"e"."f"`, and return both the exported local trampoline and
|
||||
/// the underlying `Instance` so callers can keep it alive (browsers can
|
||||
/// otherwise GC the instance and orphan the funcref's body).
|
||||
fn instantiate_wrapper(bytes: &[u8], js_fn: &JsValue) -> (JsValue, JsValue) {
|
||||
let u8arr = Uint8Array::from(bytes);
|
||||
let module = WebAssembly::Module::new(&u8arr.into()).unwrap();
|
||||
let env = Object::new();
|
||||
@@ -537,6 +572,8 @@ fn make_wrapper_module_i32(js_fn: &JsValue) -> JsValue {
|
||||
let imports = Object::new();
|
||||
Reflect::set(&imports, &"e".into(), &env).unwrap();
|
||||
let instance = WebAssembly::Instance::new(&module, &imports).unwrap();
|
||||
let instance_jv: JsValue = instance.clone().into();
|
||||
let exports = Reflect::get(&instance, &"exports".into()).unwrap();
|
||||
Reflect::get(&exports, &"f".into()).unwrap()
|
||||
let func = Reflect::get(&exports, &"f".into()).unwrap();
|
||||
(func, instance_jv)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user