diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 3dfbf3924e..f6ee89bca0 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -140,6 +140,41 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { + let unset = |v: &JsValue| v.is_undefined() || v.is_null(); + // Only this context's own document object (not a lookalike, proxy or inherited object). + let this_document = |v: &JsValue, ctx: &Context| unset(v) || v.as_object().is_some_and(|o| JsObject::equals(&o, &ctx.global_object())); + let first = arg(args, 0); + let path = match first.as_object().filter(|o| !o.is_array() && !o.is_callable()) { + Some(o) => { + if !args.iter().skip(1).all(unset) { + return Err(error("unsupported openDoc options")); + } + let fs = o.get(js_string!("cFS"), ctx)?; + let base = o.get(js_string!("oDoc"), ctx)?; + if (!fs.is_undefined() && !text(&fs, ctx)?.is_empty()) || !this_document(&base, ctx) { + return Err(error("openDoc supports only the current document's local file system")); + } + text(&o.get(js_string!("cPath"), ctx)?, ctx)? + } + None => { + if !this_document(&arg(args, 1), ctx) || !args.iter().skip(2).all(unset) { + return Err(error("unsupported openDoc options")); + } + text(&first, ctx)? + } + }; + if path.is_empty() || path.len() > 4096 { + return Err(error("invalid openDoc path")); + } + host(ctx)?.borrow_mut().request(Request::OpenDoc(path)); + Ok(JsValue::null()) +} + /// The attached file `exportDataObject` names, and whether to open it: from an object /// (`{cName, nLaunch}`) or from the arguments (`cName, cDIPath, bAllowAuth, nLaunch`). `nLaunch` /// 1 and 2 open the file; 0 (the default) and any other value save it. @@ -555,6 +590,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(crate::interaction::response), js_string!("response"), 1) .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) .function(NativeFunction::from_fn_ptr(crate::interaction::popup_menu), js_string!("popUpMenuEx"), 0) + .function(NativeFunction::from_fn_ptr(open_doc), js_string!("openDoc"), 1) .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) @@ -622,13 +658,16 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject, .build(); let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone())); let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone())); - let target = e.target.as_ref().map_or(JsValue::null(), |t| { - if e.target_id.is_some() { - crate::document::field_object_by_id(ctx, t, e.target_id).into() - } else { - crate::document::field_object(ctx, t).into() - } - }); + // A link action's target is the document (#2081); other events without a field have none. + let link_document = e.target.is_none() && e.kind == "Link"; + let target = match e.target.as_ref() { + Some(t) if e.target_id.is_some() => crate::document::field_object_by_id(ctx, t, e.target_id).into(), + Some(t) => crate::document::field_object(ctx, t).into(), + None if link_document => ctx.global_object().into(), + None => JsValue::null(), + }; + // Not enumerable there, so `JSON.stringify(event)` doesn't walk back into the document. + let target_attributes = if link_document { Attribute::WRITABLE | Attribute::CONFIGURABLE } else { Attribute::all() }; let event = ObjectInitializer::new(ctx) .property(js_string!("name"), s(&e.name), Attribute::all()) .property(js_string!("type"), s(&e.kind), Attribute::all()) @@ -637,7 +676,7 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject, .property(js_string!("changeEx"), s(&e.change), Attribute::all()) .property(js_string!("willCommit"), e.will_commit, Attribute::all()) .property(js_string!("rc"), true, Attribute::all()) - .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("target"), target, target_attributes) .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) .property(js_string!("selStart"), 0, Attribute::all()) .property(js_string!("selEnd"), 0, Attribute::all()) diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 5966e63545..e2861c3d67 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -76,12 +76,13 @@ impl Host { Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), Request::Submit(u) => Request::Submit(xfa::clip(u)), Request::Focus(n) => Request::Focus(xfa::clip(n)), + Request::OpenDoc(p) => Request::OpenDoc(xfa::clip(p)), Request::FocusField { name, object_id } => Request::FocusField { name: xfa::clip(name), object_id }, other => other, }; let bytes = match &r { Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) | Request::OpenDoc(u) => u.len(), Request::FocusField { name, .. } => name.len(), Request::OpenAttachment { name, .. } => name.len(), _ => 0, diff --git a/crates/js/src/interaction.rs b/crates/js/src/interaction.rs index 525acf930a..38f452fac7 100644 --- a/crates/js/src/interaction.rs +++ b/crates/js/src/interaction.rs @@ -31,9 +31,9 @@ pub(crate) fn has_host() -> bool { pub(crate) fn set_host(host: Option>) { HOST.with(|h| *h.borrow_mut() = host.map(|h| (h, 0))); } -/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation. -pub(crate) fn ask(prompt: Prompt) -> JsResult> { - let sender = HOST.with(|h| { +/// Charge one prompt to the invocation's budget: the channel to send it on, if the worker has one. +fn reserve() -> JsResult>> { + HOST.with(|h| { let mut h = h.borrow_mut(); let Some((tx, n)) = h.as_mut() else { return Ok(None) }; if *n >= 32 { @@ -41,8 +41,11 @@ pub(crate) fn ask(prompt: Prompt) -> JsResult> { } *n += 1; Ok(Some(tx.clone())) - })?; - let Some(sender) = sender else { return Ok(None) }; + }) +} +/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation. +pub(crate) fn ask(prompt: Prompt) -> JsResult> { + let Some(sender) = reserve()? else { return Ok(None) }; let (tx, rx) = mpsc::channel(); sender.send(InteractiveEvent::Prompt(prompt, tx)).map_err(|_| error("interactive host closed"))?; let answer = rx.recv_timeout(std::time::Duration::from_secs(300)).ok().flatten(); @@ -102,6 +105,12 @@ pub(crate) fn popup_menu(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> Js for a in args { items.push(read_menu(a, ctx, 0, &mut remaining)?); } + // A menu with no items has nothing to pick: it answers null at once, without showing an empty + // menu or using up a later menu's answer, but still counts toward the prompt limit (#2081). + if items.is_empty() { + reserve()?; + return Ok(JsValue::null()); + } match ask(Prompt::Menu(items.clone()))? { Some(answer) if allowed(&items, &answer) => Ok(s(&answer)), Some(_) => Err(error("invalid popup menu selection")), diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 70bb677d8a..95a1076215 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -311,6 +311,9 @@ pub enum Request { Highlight(bool), /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `app.openDoc(cPath)`: the path as the script gave it (relative to this document's + /// folder). A request only: the viewer asks the user before opening any file. + OpenDoc(String), /// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch` /// 1 or 2) or to save (0). OpenAttachment { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index c79e609967..2e780486b7 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -2702,3 +2702,73 @@ fn af_simple_calculate_bounds_selector_work_before_expansion() { run("AFSimple_Calculate('SUM',new Array(4096).fill('n'));", &Event::doc("Console"), &Default::default(), &fields, &[], Default::default()); assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation field lookup exceeds"))); } + +/// A link action's `event.target` is the document (#2081, ported), and the event can still be +/// serialized: the target isn't enumerable, so `JSON.stringify` doesn't walk back into it. +#[test] +fn a_link_events_target_is_the_document() { + let link = Event { kind: "Link".into(), ..Event::doc("Mouse Up") }; + let o = go("JSON.stringify(event); JSON.stringify(this); (event.target === this) + ':' + event.type + ':' + event.name", &link); + assert_eq!(o.error, None); + assert_eq!(o.result.as_deref(), Some("true:Link:Mouse Up")); + assert_eq!(go("event.target === null", &Event::doc("Console")).result.as_deref(), Some("true"), "other document events keep none"); +} + +/// `app.openDoc` only queues a request (the viewer asks before opening a file) and answers null; +/// another document as the base, another file system or extra options are refused (#2081). +#[test] +fn open_doc_queues_a_request_for_the_current_documents_folder_only() { + let link = Event { kind: "Link".into(), ..Event::doc("Mouse Up") }; + for call in [ + "app.openDoc({cPath:'other.pdf',oDoc:this})", + "app.openDoc('other.pdf',this)", + "app.openDoc('other.pdf',event.target)", + "app.openDoc({cPath:'other.pdf',oDoc:this,cFS:''})", + "app.openDoc('other.pdf',null)", + "app.openDoc({cPath:'other.pdf'},null,undefined)", + ] { + let o = go(&format!("getField('price').value='17'; {call} === null"), &link); + assert_eq!(o.error, None, "{call}"); + assert_eq!(o.result.as_deref(), Some("true"), "{call}"); + assert_eq!(o.requests, [Request::OpenDoc("other.pdf".into())], "{call}"); + } + for call in [ + "app.openDoc({cPath:'other.pdf',oDoc:{}})", + "app.openDoc('other.pdf',{})", + "app.openDoc('other.pdf',Object.create(this))", + "app.openDoc('other.pdf',new Proxy(this,{}))", + "app.openDoc('other.pdf',getField('price'))", + "app.openDoc('other.pdf',this,true)", + "app.openDoc({cPath:'other.pdf',oDoc:this,cFS:'CHTTP'})", + "app.openDoc({cPath:'other.pdf'},this)", + "app.openDoc('')", + ] { + let o = go(call, &link); + assert!(o.error.as_deref().is_some_and(|e| e.contains("openDoc")), "{call}: {:?}", o.error); + assert!(o.requests.is_empty(), "{call}"); + } +} + +/// A popup menu with no items answers null without asking, so the next menu still gets its own +/// answer, and empty menus count toward the prompt limit (#2081). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn an_empty_popup_menu_answers_null_without_asking() { + let job = |script: &str| crate::session::Job { + script: script.into(), + event: Event { kind: "Link".into(), ..Event::doc("Mouse Up") }, + doc: doc(), + fields: Arc::new(Vec::new()), + doc_scripts: Vec::new(), + calculations: Vec::new(), + }; + let mut s = crate::session::ScriptSession::default(); + let o = s.run( + job("var a = app.popUpMenuEx(); var b = app.popUpMenuEx({cName:'B', cReturn:'b'}); app.popUpMenuEx.apply(app, []); String(a) + ':' + b"), + &[Some("b".into())], + ); + assert_eq!(o.error, None); + assert_eq!(o.result.as_deref(), Some("null:b")); + let o = s.run(job("for (var i = 0; i < 33; i++) app.popUpMenuEx();"), &[]); + assert!(o.error.as_deref().is_some_and(|e| e.contains("too many interactive prompts")), "{:?}", o.error); +}