From 9703d25690bc5d571c167bf276184e8bc26a1fee Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:30:57 -0700 Subject: [PATCH] core: Match asfunction: and event: URL schemes case-insensitively (close #23514) Submachine HD builds use capital-F `asFunction:` HREFs in their footer controls. The byte-exact `WStr::strip_prefix` rejected that variant and fell through to `navigate_to_url`, which opened the function call as an external URL in the desktop browser. Flash Player matched both `asfunction:` and `event:` schemes case-insensitively. Extract a `strip_link_scheme` helper that lowercases the URL head, strips the scheme from the lowercase copy, and slices the original `url` from the back to preserve the address's original casing for downstream consumers (the AVM1 function name and AVM2 link event text). Add unit tests covering canonical case, mixed case, upper case, empty address after scheme, non-matching URLs, and a substring-only match regression guard. cargo check -p ruffle_core, cargo clippy -p ruffle_core -- -D warnings, and cargo fmt --check all pass locally. The 7 new tests pass alongside the existing 65 ruffle_core unit tests. --- core/src/display_object/edit_text.rs | 76 +++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/core/src/display_object/edit_text.rs b/core/src/display_object/edit_text.rs index cac79046861e..69aca098ce6e 100644 --- a/core/src/display_object/edit_text.rs +++ b/core/src/display_object/edit_text.rs @@ -2468,11 +2468,11 @@ impl<'gc> EditText<'gc> { } fn open_url(self, context: &mut UpdateContext<'gc>, url: &WStr, target: &WStr) { - if let Some(address) = url.strip_prefix(WStr::from_units(b"asfunction:")) { + if let Some(address) = strip_link_scheme(url, WStr::from_units(b"asfunction:")) { if let Err(e) = self.execute_avm1_asfunction(context, address) { error!("Couldn't execute URL \"{url:?}\": {e:?}"); } - } else if let Some(address) = url.strip_prefix(WStr::from_units(b"event:")) { + } else if let Some(address) = strip_link_scheme(url, WStr::from_units(b"event:")) { if let Some(object) = self.object2() { let mut activation = Avm2Activation::from_nothing(context); let text = AvmString::new(activation.gc(), address); @@ -3662,6 +3662,19 @@ impl<'gc> EditTextStyleSheet<'gc> { } } +/// Flash matches the `asfunction:` and `event:` URL schemes case-insensitively, +/// so HREFs like `asFunction:toggle_music` (Submachine HD builds, #23514) must +/// route the same way as `asfunction:toggle_music`. `WStr::strip_prefix` is +/// byte-exact, so we do the prefix check against a lowercased head and then +/// slice the original URL from the back to preserve the address's original +/// casing for downstream consumers (the AVM1 function name, the AVM2 link +/// event text). +fn strip_link_scheme<'a>(url: &'a WStr, scheme: &WStr) -> Option<&'a WStr> { + let lowercase = url.to_ascii_lowercase(); + let tail_len = lowercase.strip_prefix(scheme)?.len(); + Some(&url[url.len() - tail_len..]) +} + #[derive(Clone, Debug)] struct ImeData { ime_start: usize, @@ -3675,3 +3688,62 @@ struct EditTextRenderState { /// rendered outside of the text mask. draw_caret_command: Option, } + +#[cfg(test)] +mod tests { + use super::strip_link_scheme; + use crate::string::{WStr, WString}; + + fn ws(s: &str) -> WString { + WString::from_utf8(s) + } + + #[test] + fn asfunction_canonical_case_matches() { + let url = ws("asfunction:toggle_music"); + let address = strip_link_scheme(&url, WStr::from_units(b"asfunction:")); + assert_eq!(address, Some(WStr::from_units(b"toggle_music"))); + } + + #[test] + fn asfunction_mixed_case_matches_and_preserves_address_case() { + let url = ws("asFunction:ToggleMusic"); + let address = strip_link_scheme(&url, WStr::from_units(b"asfunction:")); + assert_eq!(address, Some(WStr::from_units(b"ToggleMusic"))); + } + + #[test] + fn asfunction_upper_case_matches() { + let url = ws("ASFUNCTION:play"); + let address = strip_link_scheme(&url, WStr::from_units(b"asfunction:")); + assert_eq!(address, Some(WStr::from_units(b"play"))); + } + + #[test] + fn event_mixed_case_matches() { + let url = ws("Event:clicked"); + let address = strip_link_scheme(&url, WStr::from_units(b"event:")); + assert_eq!(address, Some(WStr::from_units(b"clicked"))); + } + + #[test] + fn non_matching_scheme_returns_none() { + let url = ws("https://example.com/"); + assert!(strip_link_scheme(&url, WStr::from_units(b"asfunction:")).is_none()); + assert!(strip_link_scheme(&url, WStr::from_units(b"event:")).is_none()); + } + + #[test] + fn empty_address_after_scheme_returns_empty_slice() { + let url = ws("asfunction:"); + let address = strip_link_scheme(&url, WStr::from_units(b"asfunction:")); + assert_eq!(address, Some(WStr::from_units(b""))); + } + + #[test] + fn scheme_prefix_only_substring_does_not_match() { + // "asfunction" without the trailing colon should not match "asfunction:" + let url = ws("asfunctionfoo"); + assert!(strip_link_scheme(&url, WStr::from_units(b"asfunction:")).is_none()); + } +}