diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 729d124..ec05bb3 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -882,8 +882,6 @@ impl Page { async fn execute_scripts(&mut self) { tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some()); - // Compute document base URL, respecting . - let document_base = self.resolve_base_url(); // Soft deadline on the entire script-execution phase. Heavy SPAs // (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial // fetch + execute loop can blow past a Puppeteer/Playwright goto @@ -915,37 +913,80 @@ impl Page { .as_mut() .map(|js| js.arm_watchdog(std::time::Duration::from_millis(script_deadline_ms + 1000))); + #[derive(Debug, Clone, Copy)] + enum ScriptKind { + Classic, + Module, + ImportMap, + } + #[derive(Debug)] struct ScriptInfo { src: Option, inline: String, is_defer: bool, is_async: bool, - is_module: bool, + kind: ScriptKind, nid: u32, + /// Document base URL at this element's parser encounter point. + base_url: String, } let all_scripts = match &self.js { Some(js) => { + let document_url = self.url_string(); js.with_dom(|dom| { let script_ids = dom.query_selector_all("script").unwrap_or_default(); + let mut bases_at_script = std::collections::HashMap::new(); + let mut active_base = url::Url::parse(&document_url).ok(); + let mut found_base = false; + for nid in dom.descendants(dom.document()) { + let Some(node) = dom.get_node(nid) else { + continue; + }; + let Some(name) = node.as_element() else { + continue; + }; + if name.local.as_ref() == "base" && !found_base { + if let Some(href) = node.get_attribute("href") { + found_base = true; + if let Some(resolved) = active_base + .as_ref() + .and_then(|base| base.join(href).ok()) + { + active_base = Some(resolved); + } + } + } else if name.local.as_ref() == "script" { + bases_at_script.insert( + nid.raw(), + active_base + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| document_url.clone()), + ); + } + } let mut scripts = Vec::new(); for sid in script_ids { if let Some(node) = dom.get_node(sid) { let src = node.get_attribute("src").map(|s| s.to_string()); - let script_type = node.get_attribute("type").unwrap_or("").to_string(); + let script_type = node + .get_attribute("type") + .unwrap_or("") + .trim() + .to_ascii_lowercase(); let is_defer = node.get_attribute("defer").is_some(); let is_async = node.get_attribute("async").is_some(); - let is_module = script_type == "module"; - - if !script_type.is_empty() - && script_type != "text/javascript" - && script_type != "application/javascript" - && script_type != "module" - { - continue; - } + let kind = match script_type.as_str() { + "module" => ScriptKind::Module, + "importmap" => ScriptKind::ImportMap, + "" | "text/javascript" | "application/javascript" => { + ScriptKind::Classic + } + _ => continue, + }; let inline_code = if src.is_none() { dom.text_content(sid) @@ -953,14 +994,21 @@ impl Page { String::new() }; - if src.is_some() || !inline_code.trim().is_empty() { + if matches!(kind, ScriptKind::ImportMap) + || src.is_some() + || !inline_code.trim().is_empty() + { scripts.push(ScriptInfo { src, inline: inline_code, is_defer, is_async, - is_module, + kind, nid: sid.raw(), + base_url: bases_at_script + .get(&sid.raw()) + .cloned() + .unwrap_or_else(|| document_url.clone()), }); } } @@ -987,45 +1035,22 @@ impl Page { ); } - let mut regular = Vec::new(); - let mut deferred = Vec::new(); - let mut async_scripts = Vec::new(); - - let mut module_scripts = Vec::new(); - - for script in all_scripts { - if script.is_module { - module_scripts.push(script); - continue; - } - if script.is_defer { - deferred.push(script); - } else if script.is_async { - async_scripts.push(script); - } else { - regular.push(script); - } - } - - let scripts = regular; - - tracing::info!("Found {} regular + {} deferred + {} async scripts", scripts.len(), deferred.len(), async_scripts.len()); - let all_to_execute: Vec = scripts.into_iter() - .chain(deferred.into_iter()) - .chain(async_scripts.into_iter()) - .collect(); - - let mut resolved: Vec<(usize, String)> = Vec::new(); + tracing::info!("Found {} parser-discovered scripts", all_scripts.len()); let mut fetch_tasks: Vec<(usize, String)> = Vec::new(); - for (i, script) in all_to_execute.iter().enumerate() { + for (i, script) in all_scripts.iter().enumerate() { + if !matches!(script.kind, ScriptKind::Classic) { + continue; + } if let Some(src_url) = &script.src { let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") { src_url.clone() - } else if let Some(base) = &document_base { - base.join(src_url).map(|u| u.to_string()).unwrap_or_else(|_| src_url.clone()) } else { - src_url.clone() + url::Url::parse(&script.base_url) + .ok() + .and_then(|base| base.join(src_url).ok()) + .map(|url| url.to_string()) + .unwrap_or_else(|| src_url.clone()) }; if !subresource_allowed(self.url.as_ref(), &full_url) { @@ -1045,7 +1070,6 @@ impl Page { tracing::info!("Blocked script by interception: {}", full_url); continue; } - resolved.push((i, full_url.clone())); fetch_tasks.push((i, full_url)); } } @@ -1162,37 +1186,6 @@ impl Page { } } - for (i, script) in all_to_execute.iter().enumerate() { - if tokio::time::Instant::now() >= script_deadline { - tracing::warn!( - "execute_scripts: deadline reached, skipping {} remaining scripts", - all_to_execute.len() - i, - ); - break; - } - if script.src.is_some() { - if let Some((url, code, resp)) = fetched.remove(&i) { - tracing::info!("Executing script ({} bytes): {}", code.len(), url); - self.record_network_event_with_body(&url, "GET", "Script", resp.status, &resp.headers, &resp.body, false); - if let Some(js) = &mut self.js { - let _ = js.execute_script("", &format!("globalThis.__currentScriptNid={};", script.nid)); - if let Err(e) = js.execute_script_guarded(&url, &code) { - tracing::warn!("Script error ({}): {}", url, e); - } - let _ = js.execute_script("", "globalThis.__currentScriptNid=0;"); - } - } - } else if !script.inline.is_empty() { - if let Some(js) = &mut self.js { - let _ = js.execute_script("", &format!("globalThis.__currentScriptNid={};", script.nid)); - if let Err(e) = js.execute_script_guarded("", &script.inline) { - tracing::warn!("Inline script error: {}", e); - } - let _ = js.execute_script("", "globalThis.__currentScriptNid=0;"); - } - } - } - // Per-module budget. Modules on an already-rendered page are // enhancement, not the app: give them a short budget so one slow // non-essential module (e.g. YC's bookface, whose top-level eval @@ -1222,37 +1215,184 @@ impl Page { if body_nodes > 50 { short_ms } else { script_deadline_ms } }; - for module_script in &module_scripts { + enum ScheduledScript { + Classic(usize), + Module { + prepared: obscura_js::runtime::PreparedModule, + url: Option, + }, + } + + let execute_classic = | + page: &mut Self, + script: &ScriptInfo, + fetched_script: Option<(String, String, obscura_net::Response)>, + | { + if script.src.is_some() { + if let Some((url, code, resp)) = fetched_script { + tracing::info!("Executing script ({} bytes): {}", code.len(), url); + let execution_url = resp.url.to_string(); + page.record_network_event_with_body( + &url, "GET", "Script", resp.status, &resp.headers, &resp.body, false, + ); + if let Some(js) = &mut page.js { + let _ = js.execute_script( + "", + &format!("globalThis.__currentScriptNid={};", script.nid), + ); + if let Err(error) = js.execute_script_guarded(&execution_url, &code) { + tracing::warn!("Script error ({}): {}", execution_url, error); + } + let _ = js.execute_script( + "", + "globalThis.__currentScriptNid=0;", + ); + } + } + } else if !script.inline.is_empty() { + if let Some(js) = &mut page.js { + let _ = js.execute_script( + "", + &format!("globalThis.__currentScriptNid={};", script.nid), + ); + if let Err(error) = js.execute_script_guarded(&script.base_url, &script.inline) { + tracing::warn!("Inline script error: {}", error); + } + let _ = js.execute_script( + "", + "globalThis.__currentScriptNid=0;", + ); + } + } + }; + + let mut post_parse = Vec::new(); + + // Process parser-discovered scripts in encounter order. Import maps + // register at their exact position; module graphs start there too, but + // evaluation of non-async modules remains post-parse. + for (index, script) in all_scripts.iter().enumerate() { if tokio::time::Instant::now() >= script_deadline { - tracing::warn!("execute_scripts: deadline reached, skipping remaining module scripts"); + tracing::warn!( + "execute_scripts: deadline reached, skipping {} remaining scripts", + all_scripts.len() - index, + ); break; } - if let Some(ref src) = module_script.src { - let full_url = if src.starts_with("http://") || src.starts_with("https://") { - src.clone() - } else if let Some(base) = &document_base { - base.join(src).map(|u| u.to_string()).unwrap_or_else(|_| src.clone()) - } else { - src.clone() - }; - tracing::info!("Loading ES module: {}", full_url); - if let Some(js) = &mut self.js { - match js.load_module(&full_url, module_budget_ms).await { - Ok(()) => { - tracing::info!("ES module loaded: {}", full_url); - self.record_network_event(&full_url, "GET", "Script", 200, &std::collections::HashMap::new(), 0); - } - Err(e) => { - tracing::warn!("ES module error ({}): {}", full_url, e); + match script.kind { + ScriptKind::ImportMap => { + if script.src.is_some() { + tracing::warn!("External import maps are not supported"); + continue; + } + if let Some(js) = &self.js { + if let Err(error) = js.add_import_map(&script.inline, &script.base_url) { + tracing::warn!("Ignoring invalid import map: {}", error); } } } - } else if !module_script.inline.is_empty() { - let base = self.url_string(); - if let Some(js) = &mut self.js { - if let Err(e) = js.load_inline_module(&module_script.inline, &base, module_budget_ms).await { - tracing::warn!("Inline ES module error: {}", e); + ScriptKind::Classic => { + if script.is_defer && !script.is_async && script.src.is_some() { + post_parse.push(ScheduledScript::Classic(index)); + } else { + let fetched_script = fetched.remove(&index); + execute_classic(self, script, fetched_script); + } + } + ScriptKind::Module => { + let (prepared, module_url) = if let Some(src) = &script.src { + let full_url = if src.starts_with("http://") + || src.starts_with("https://") + || src.starts_with("data:") + { + src.clone() + } else { + url::Url::parse(&script.base_url) + .ok() + .and_then(|base| base.join(src).ok()) + .map(|url| url.to_string()) + .unwrap_or_else(|| src.clone()) + }; + tracing::info!("Preparing ES module graph: {}", full_url); + let result = match &mut self.js { + Some(js) => js.prepare_module(&full_url, module_budget_ms).await, + None => continue, + }; + match result { + Ok(prepared) => (prepared, Some(full_url)), + Err(error) => { + tracing::warn!("ES module error ({}): {}", full_url, error); + continue; + } + } + } else { + let result = match &mut self.js { + Some(js) => js.prepare_inline_module( + &script.inline, &script.base_url, module_budget_ms, + ).await, + None => continue, + }; + match result { + Ok(prepared) => (prepared, None), + Err(error) => { + tracing::warn!("Inline ES module error: {}", error); + continue; + } + } + }; + let scheduled = ScheduledScript::Module { + prepared, + url: module_url, + }; + if script.is_async { + let ScheduledScript::Module { prepared, url } = scheduled else { + unreachable!(); + }; + let result = match &mut self.js { + Some(js) => js.evaluate_prepared_module(prepared, module_budget_ms).await, + None => continue, + }; + if let Err(error) = result { + tracing::warn!("ES module evaluation error: {}", error); + } else if let Some(url) = url { + tracing::info!("ES module loaded: {}", url); + self.record_network_event( + &url, "GET", "Script", 200, + &std::collections::HashMap::new(), 0, + ); + } + } else { + post_parse.push(scheduled); + } + } + } + } + + for scheduled in post_parse { + if tokio::time::Instant::now() >= script_deadline { + tracing::warn!("execute_scripts: deadline reached during post-parse scripts"); + break; + } + match scheduled { + ScheduledScript::Classic(index) => { + let script = &all_scripts[index]; + let fetched_script = fetched.remove(&index); + execute_classic(self, script, fetched_script); + } + ScheduledScript::Module { prepared, url } => { + let result = match &mut self.js { + Some(js) => js.evaluate_prepared_module(prepared, module_budget_ms).await, + None => continue, + }; + if let Err(error) = result { + tracing::warn!("ES module evaluation error: {}", error); + } else if let Some(url) = url { + tracing::info!("ES module loaded: {}", url); + self.record_network_event( + &url, "GET", "Script", 200, + &std::collections::HashMap::new(), 0, + ); } } } @@ -2361,6 +2501,46 @@ mod tests { }; use obscura_dom::parse_html; + fn spawn_parser_import_map_server( + expected_requests: usize, + ) -> (String, std::sync::mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (request_tx, request_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + use std::io::{Read as _, Write as _}; + + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 2048]; + let length = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..length]); + let path = request + .lines().next() + .and_then(|line| line.split_ascii_whitespace().nth(1)) + .unwrap_or("/").to_string(); + request_tx.send(path.clone()).unwrap(); + let (status, body) = match path.as_str() { + "/app/before.js" => ("200 OK", "export const value = 'before-first-module';"), + "/app/later.js" => ("200 OK", "export const value = 'later-map';"), + "/app/async.js" => ( + "200 OK", + "import('too-late')\ + .then(module => globalThis.__async_before_map = module.value)\ + .catch(() => globalThis.__async_before_map = 'rejected');", + ), + _ => ("404 Not Found", "not found"), + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + (format!("http://{}", address), request_rx) + } + #[test] fn external_scripts_require_a_successful_http_status() { assert!(script_response_is_executable(200)); @@ -2373,6 +2553,164 @@ mod tests { assert!(!script_response_is_executable(500)); } + fn import_map_test_page(name: &str, base: &str, html: &str) -> super::Page { + let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network( + name.to_string(), None, false, None, None, true, + )); + let mut page = super::Page::new(name.to_string(), context); + page.url = Some(url::Url::parse(&format!("{}/app/index.html", base)).unwrap()); + page.dom = Some(parse_html(html)); + page.init_js(); + page + } + + #[tokio::test(flavor = "current_thread")] + async fn parser_import_map_before_first_module_controls_resolution() { + let (base, requests) = spawn_parser_import_map_server(1); + let mut page = import_map_test_page("import-map-order", &base, r#" + + + + "#); + page.execute_scripts().await; + + assert_eq!( + page.js.as_mut().unwrap().evaluate("globalThis.__parser_import_map_value").unwrap(), + serde_json::json!("before-first-module"), + ); + assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/before.js"); + } + + #[tokio::test(flavor = "current_thread")] + async fn later_import_map_adds_unrelated_rule_without_rebinding_resolved_rule() { + let (base, requests) = spawn_parser_import_map_server(2); + let mut page = import_map_test_page("multiple-import-map-order", &base, r#" + + + + + "#); + page.execute_scripts().await; + + let js = page.js.as_mut().unwrap(); + assert_eq!(js.evaluate("globalThis.__first_map_value").unwrap(), serde_json::json!("before-first-module")); + assert_eq!(js.evaluate("globalThis.__later_map_values").unwrap(), serde_json::json!(["before-first-module", "later-map"])); + let paths = (0..2) + .map(|_| requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap()) + .collect::>(); + assert!(paths.contains(&"/app/before.js".to_string()), "{paths:?}"); + assert!(paths.contains(&"/app/later.js".to_string()), "{paths:?}"); + assert!(!paths.contains(&"/app/after.js".to_string()), "{paths:?}"); + } + + #[tokio::test(flavor = "current_thread")] + async fn classic_dynamic_import_does_not_see_a_later_parser_import_map() { + let (base, _requests) = spawn_parser_import_map_server(1); + let mut page = import_map_test_page("classic-before-import-map", &base, r#" + + + "#); + page.execute_scripts().await; + assert_eq!( + page.js.as_mut().unwrap().evaluate("globalThis.__classic_before_map").unwrap(), + serde_json::json!("rejected"), + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn ready_async_classic_script_runs_before_a_later_parser_import_map() { + let (base, requests) = spawn_parser_import_map_server(2); + let mut page = import_map_test_page("async-classic-before-map", &base, r#" + + + "#); + page.execute_scripts().await; + assert_eq!( + page.js.as_mut().unwrap().evaluate("globalThis.__async_before_map").unwrap(), + serde_json::json!("rejected"), + ); + assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/async.js"); + assert!(requests.try_recv().is_err()); + } + + #[tokio::test(flavor = "current_thread")] + async fn dynamically_inserted_import_map_controls_later_dynamic_import() { + let (base, requests) = spawn_parser_import_map_server(1); + let mut page = import_map_test_page("dynamic-import-map", &base, r#" + + "#); + page.execute_scripts().await; + assert_eq!( + page.js.as_mut().unwrap().evaluate("globalThis.__dynamic_map_value").unwrap(), + serde_json::json!("later-map"), + ); + assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/later.js"); + } + + #[tokio::test(flavor = "current_thread")] + async fn dynamic_import_map_uses_live_document_base_at_insertion() { + let (base, requests) = spawn_parser_import_map_server(1); + let mut page = import_map_test_page("dynamic-import-map-base", &base, r#" + + "#); + page.execute_scripts().await; + assert_eq!( + page.js.as_mut().unwrap().evaluate("globalThis.__dynamic_map_base").unwrap(), + serde_json::json!("later-map"), + ); + assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/later.js"); + } + + #[tokio::test(flavor = "current_thread")] + async fn later_base_element_does_not_rebase_an_earlier_import_map() { + let (base, requests) = spawn_parser_import_map_server(1); + let mut page = import_map_test_page("temporal-import-map-base", &base, r#" + + + + "#); + page.execute_scripts().await; + assert_eq!( + page.js.as_mut().unwrap().evaluate("globalThis.__temporal_base_value").unwrap(), + serde_json::json!("before-first-module"), + ); + assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/before.js"); + } + #[cfg(feature = "render")] #[test] fn page_screenshot_uses_the_live_window_scroll_offset() { diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index fdd1160..d82093c 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1159,8 +1159,33 @@ class Node { if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []); if (c instanceof Element && c.tagName === 'SCRIPT') { if (__startedScriptNids.has(c._nid)) return c; - const scriptType = c.getAttribute('type') || ''; + const scriptType = (c.getAttribute('type') || '').trim().toLowerCase(); const isModule = scriptType === 'module'; + const isImportMap = scriptType === 'importmap'; + if (isImportMap) { + __startedScriptNids.add(c._nid); + const src = c.getAttribute('src'); + let error = ''; + if (src) { + error = 'External import maps are not supported'; + } else { + const base = c.baseURI + || globalThis.location?.href + || 'about:blank'; + try { + error = Deno.core.ops.op_add_import_map(c.textContent || '', base) || ''; + } catch (e) { + error = e && e.message ? e.message : String(e); + } + } + if (error) { + console.error('Import map error:', error); + queueMicrotask(() => { + try { c.dispatchEvent(new Event('error')); } catch (_) {} + }); + } + return c; + } if (scriptType && !isModule && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') { return c; } diff --git a/crates/obscura-js/src/import_map.rs b/crates/obscura-js/src/import_map.rs new file mode 100644 index 0000000..bfeda32 --- /dev/null +++ b/crates/obscura-js/src/import_map.rs @@ -0,0 +1,455 @@ +use std::collections::HashMap; + +use deno_core::ModuleSpecifier; + +#[derive(Default)] +pub(crate) struct ImportMap { + imports: SpecifierMap, + scopes: Vec<(String, SpecifierMap)>, + resolved_modules: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResolvedModule { + referrer: String, + specifier: String, + as_url_is_special: bool, +} + +#[derive(Default)] +struct SpecifierMap { + entries: HashMap>, + prefixes: Vec, +} + +impl ImportMap { + pub(crate) fn parse(input: &str, base_url: &str) -> Result { + let base = ModuleSpecifier::parse(base_url) + .map_err(|e| format!("Invalid import map base URL {}: {}", base_url, e))?; + let parsed: serde_json::Value = + serde_json::from_str(input).map_err(|e| format!("Invalid import map JSON: {}", e))?; + let object = parsed + .as_object() + .ok_or_else(|| "Import map top level must be an object".to_string())?; + + let imports = match object.get("imports") { + Some(value) => SpecifierMap::parse( + value + .as_object() + .ok_or_else(|| "Import map \"imports\" must be an object".to_string())?, + &base, + ), + None => SpecifierMap::default(), + }; + + let mut scopes = Vec::new(); + if let Some(value) = object.get("scopes") { + let scope_object = value + .as_object() + .ok_or_else(|| "Import map \"scopes\" must be an object".to_string())?; + for (scope_prefix, scope_imports) in scope_object { + // Unlike keys and addresses in a module specifier map, scope + // prefixes use ordinary URL parsing. In particular, a bare + // relative value such as "feature/" is valid here. + let Ok(normalized_scope) = base.join(scope_prefix) else { + continue; + }; + let scope_imports = scope_imports.as_object().ok_or_else(|| { + format!( + "Import map scope \"{}\" must contain an object", + scope_prefix + ) + })?; + scopes.push(( + normalized_scope.to_string(), + SpecifierMap::parse(scope_imports, &base), + )); + } + // The most specific applicable scope is consulted first. + scopes.sort_by(|left, right| right.0.cmp(&left.0)); + } + + // Integrity enforcement is owned by the module fetch layer. Still + // validate the top-level shape here: a malformed integrity member + // invalidates the complete import map in Chromium/the HTML algorithm, + // rather than leaving its `imports` member active. + if let Some(value) = object.get("integrity") { + value + .as_object() + .ok_or_else(|| "Import map \"integrity\" must be an object".to_string())?; + } + + Ok(Self { + imports, + scopes, + resolved_modules: Vec::new(), + }) + } + + pub(crate) fn merge(&mut self, mut new_map: Self) { + // Multiple import maps are merged even after module graphs have + // started. New rules which could change an already-observed + // (referrer, specifier) resolution are removed first; unrelated rules + // remain available to later graphs. + for record in &self.resolved_modules { + new_map + .imports + .remove_rules_affecting(&record.specifier, record.as_url_is_special); + for (scope_prefix, scope_imports) in &mut new_map.scopes { + if scope_applies(scope_prefix, &record.referrer) { + scope_imports + .remove_rules_affecting(&record.specifier, record.as_url_is_special); + } + } + } + + self.imports.merge(new_map.imports); + for (scope_prefix, new_imports) in new_map.scopes { + if let Some((_, imports)) = self + .scopes + .iter_mut() + .find(|(existing, _)| existing == &scope_prefix) + { + imports.merge(new_imports); + } else { + self.scopes.push((scope_prefix, new_imports)); + } + } + self.scopes.sort_by(|left, right| right.0.cmp(&left.0)); + } + + pub(crate) fn resolve( + &mut self, + specifier: &str, + referrer: &ModuleSpecifier, + ) -> Result { + let as_url = resolve_url_like(specifier, referrer); + let normalized = as_url + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| specifier.to_string()); + let serialized_referrer = referrer.to_string(); + + for (scope_prefix, scope_imports) in &self.scopes { + if scope_applies(scope_prefix, &serialized_referrer) { + if let Some(resolved) = scope_imports.resolve_match(&normalized, as_url.as_ref())? { + self.remember_resolution(serialized_referrer, normalized, as_url.as_ref()); + return Ok(resolved); + } + } + } + + if let Some(resolved) = self.imports.resolve_match(&normalized, as_url.as_ref())? { + self.remember_resolution(serialized_referrer, normalized, as_url.as_ref()); + return Ok(resolved); + } + match as_url { + Some(resolved) => { + self.remember_resolution(serialized_referrer, normalized, Some(&resolved)); + Ok(resolved) + } + None => Err(format!( + "Bare module specifier \"{}\" was not remapped by the import map", + specifier + )), + } + } + + fn remember_resolution( + &mut self, + referrer: String, + specifier: String, + as_url: Option<&ModuleSpecifier>, + ) { + let resolution = ResolvedModule { + referrer, + specifier, + as_url_is_special: as_url.is_none_or(is_special_url), + }; + if !self.resolved_modules.contains(&resolution) { + self.resolved_modules.push(resolution); + } + } +} + +impl SpecifierMap { + fn parse(object: &serde_json::Map, base: &ModuleSpecifier) -> Self { + let mut entries = HashMap::with_capacity(object.len()); + let mut prefixes = Vec::new(); + for (key, value) in object { + let Some(normalized_key) = normalize_key(key, base) else { + continue; + }; + let address = value + .as_str() + .and_then(|address| resolve_url_like(address, base)) + .filter(|address| !key.ends_with('/') || address.as_str().ends_with('/')); + if normalized_key.ends_with('/') { + prefixes.push(normalized_key.clone()); + } + entries.insert(normalized_key, address); + } + prefixes.sort_by(|left, right| right.cmp(left)); + Self { entries, prefixes } + } + + fn merge(&mut self, new_map: Self) { + for (key, address) in new_map.entries { + if !self.entries.contains_key(&key) { + if key.ends_with('/') { + self.prefixes.push(key.clone()); + } + self.entries.insert(key, address); + } + } + self.prefixes.sort_by(|left, right| right.cmp(left)); + } + + fn remove_rules_affecting(&mut self, resolved: &str, as_url_is_special: bool) { + self.entries.retain(|key, _| { + key != resolved + && !(as_url_is_special && key.ends_with('/') && resolved.starts_with(key.as_str())) + }); + self.prefixes + .retain(|key| self.entries.contains_key(key.as_str())); + } + + fn resolve_match( + &self, + normalized: &str, + as_url: Option<&ModuleSpecifier>, + ) -> Result, String> { + if let Some(address) = self.entries.get(normalized) { + return address.clone().map(Some).ok_or_else(|| { + format!( + "Module specifier \"{}\" is blocked by import map entry \"{}\"", + normalized, normalized + ) + }); + } + + if as_url.is_some_and(|url| !is_special_url(url)) { + return Ok(None); + } + + for key in self + .prefixes + .iter() + .filter(|key| normalized.starts_with(key.as_str())) + { + let address = self + .entries + .get(key) + .ok_or_else(|| format!("Import map prefix \"{}\" has no matching address", key))?; + let address = address.as_ref().ok_or_else(|| { + format!( + "Module specifier \"{}\" is blocked by import map prefix \"{}\"", + normalized, key + ) + })?; + let after_prefix = &normalized[key.len()..]; + let resolved = address.join(after_prefix).map_err(|e| { + format!( + "Module specifier \"{}\" could not resolve through import map prefix \"{}\": {}", + normalized, key, e + ) + })?; + if !resolved.as_str().starts_with(address.as_str()) { + return Err(format!( + "Module specifier \"{}\" backtracks above import map prefix \"{}\"", + normalized, key + )); + } + return Ok(Some(resolved)); + } + Ok(None) + } +} + +fn normalize_key(key: &str, base: &ModuleSpecifier) -> Option { + if key.is_empty() { + return None; + } + Some( + resolve_url_like(key, base) + .map(|url| url.to_string()) + .unwrap_or_else(|| key.to_string()), + ) +} + +fn resolve_url_like(specifier: &str, base: &ModuleSpecifier) -> Option { + if specifier.starts_with('/') || specifier.starts_with("./") || specifier.starts_with("../") { + base.join(specifier).ok() + } else { + ModuleSpecifier::parse(specifier).ok() + } +} + +fn scope_applies(scope_prefix: &str, referrer: &str) -> bool { + scope_prefix == referrer || (scope_prefix.ends_with('/') && referrer.starts_with(scope_prefix)) +} + +fn is_special_url(url: &ModuleSpecifier) -> bool { + matches!( + url.scheme(), + "ftp" | "file" | "http" | "https" | "ws" | "wss" + ) +} + +#[cfg(test)] +mod tests { + use super::ImportMap; + + #[test] + fn exact_prefix_and_url_like_keys_are_normalized_against_the_map_base() { + let mut map = ImportMap::parse( + r#"{ + "imports": { + "pkg": "../vendor/pkg.js", + "pkg/": "../vendor/pkg/", + "./local.js": "../vendor/local.js" + } + }"#, + "https://example.test/app/maps/import-map.json", + ) + .unwrap(); + let referrer = + deno_core::ModuleSpecifier::parse("https://example.test/app/main.js").unwrap(); + + assert_eq!( + map.resolve("pkg", &referrer).unwrap().as_str(), + "https://example.test/app/vendor/pkg.js", + ); + assert_eq!( + map.resolve("pkg/features/a.js", &referrer) + .unwrap() + .as_str(), + "https://example.test/app/vendor/pkg/features/a.js", + ); + assert_eq!( + map.resolve("./maps/dir/../local.js", &referrer) + .unwrap() + .as_str(), + "https://example.test/app/vendor/local.js", + ); + } + + #[test] + fn most_specific_scope_wins_then_falls_back_to_top_level_imports() { + let mut map = ImportMap::parse( + r#"{ + "imports": { + "shared": "/default.js", + "fallback": "/fallback.js" + }, + "scopes": { + "/feature/": { "shared": "/feature.js" }, + "/feature/nested/": { "shared": "/nested.js" } + } + }"#, + "https://example.test/app/index.html", + ) + .unwrap(); + + let nested = + deno_core::ModuleSpecifier::parse("https://example.test/feature/nested/main.js") + .unwrap(); + assert_eq!( + map.resolve("shared", &nested).unwrap().as_str(), + "https://example.test/nested.js", + ); + assert_eq!( + map.resolve("fallback", &nested).unwrap().as_str(), + "https://example.test/fallback.js", + ); + } + + #[test] + fn later_maps_add_unrelated_rules_but_cannot_change_resolved_rules() { + let mut map = ImportMap::parse( + r#"{"imports":{"fixed":"/first.js"}}"#, + "https://example.test/app/index.html", + ) + .unwrap(); + let referrer = + deno_core::ModuleSpecifier::parse("https://example.test/app/main.js").unwrap(); + + assert_eq!( + map.resolve("fixed", &referrer).unwrap().as_str(), + "https://example.test/first.js", + ); + map.merge( + ImportMap::parse( + r#"{"imports":{"fixed":"/second.js","later":"/later.js"}}"#, + "https://example.test/app/index.html", + ) + .unwrap(), + ); + + assert_eq!( + map.resolve("fixed", &referrer).unwrap().as_str(), + "https://example.test/first.js", + ); + assert_eq!( + map.resolve("later", &referrer).unwrap().as_str(), + "https://example.test/later.js", + ); + } + + #[test] + fn later_prefix_rules_cannot_capture_an_already_resolved_specifier() { + let mut map = ImportMap::default(); + let referrer = + deno_core::ModuleSpecifier::parse("https://example.test/app/main.js").unwrap(); + assert_eq!( + map.resolve("./pkg/item.js", &referrer).unwrap().as_str(), + "https://example.test/app/pkg/item.js", + ); + + map.merge( + ImportMap::parse( + r#"{"imports":{"./pkg/":"/replacement/","new/":"/new/"}}"#, + "https://example.test/app/main.js", + ) + .unwrap(), + ); + + assert_eq!( + map.resolve("./pkg/item.js", &referrer).unwrap().as_str(), + "https://example.test/app/pkg/item.js", + ); + assert_eq!( + map.resolve("new/item.js", &referrer).unwrap().as_str(), + "https://example.test/new/item.js", + ); + } + + #[test] + fn bare_relative_scope_prefix_is_resolved_as_a_url() { + let mut map = ImportMap::parse( + r#"{"scopes":{"feature/":{"pkg":"/scoped.js"}}}"#, + "https://example.test/app/index.html", + ) + .unwrap(); + let referrer = + deno_core::ModuleSpecifier::parse("https://example.test/app/feature/main.js").unwrap(); + assert_eq!( + map.resolve("pkg", &referrer).unwrap().as_str(), + "https://example.test/scoped.js", + ); + } + + #[test] + fn malformed_scope_or_integrity_member_invalidates_the_complete_map() { + assert!(ImportMap::parse( + r#"{"imports":{"pkg":"/pkg.js"},"scopes":{"/app/":[]}}"#, + "https://example.test/index.html", + ) + .is_err()); + assert!(ImportMap::parse( + r#"{"imports":{"pkg":"/pkg.js"},"integrity":[]}"#, + "https://example.test/index.html", + ) + .is_err()); + } +} diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs index 3b95288..1a99384 100644 --- a/crates/obscura-js/src/lib.rs +++ b/crates/obscura-js/src/lib.rs @@ -1,4 +1,5 @@ pub mod cdp_watchdog; +mod import_map; pub mod module_loader; pub mod runtime; pub mod ops; diff --git a/crates/obscura-js/src/module_loader.rs b/crates/obscura-js/src/module_loader.rs index 2d71676..b2b92e7 100644 --- a/crates/obscura-js/src/module_loader.rs +++ b/crates/obscura-js/src/module_loader.rs @@ -11,6 +11,7 @@ use deno_core::ModuleSourceCode; use deno_core::ModuleSpecifier; use deno_core::RequestedModuleType; +use crate::import_map::ImportMap; use crate::ops::ObscuraState; pub struct ObscuraModuleLoader { @@ -27,6 +28,7 @@ pub struct ObscuraModuleLoader { /// Directly-constructed loaders still use Obscura's network policy and /// connection pool; they simply have an isolated cookie jar. standalone_client: Option>, + import_map: Rc>, } impl ObscuraModuleLoader { @@ -35,6 +37,15 @@ impl ObscuraModuleLoader { } pub fn with_proxy(base_url: &str, proxy_url: Option) -> Self { + let import_map = Rc::new(RefCell::new(ImportMap::default())); + Self::with_proxy_and_import_map(base_url, proxy_url, import_map) + } + + fn with_proxy_and_import_map( + base_url: &str, + proxy_url: Option, + import_map: Rc>, + ) -> Self { let standalone_client = Arc::new(obscura_net::ObscuraHttpClient::with_options( Arc::new(obscura_net::CookieJar::new()), proxy_url.as_deref(), @@ -44,6 +55,7 @@ impl ObscuraModuleLoader { proxy_url, page_state: None, standalone_client: Some(standalone_client), + import_map, } } @@ -51,12 +63,14 @@ impl ObscuraModuleLoader { base_url: &str, proxy_url: Option, page_state: &Rc>, + import_map: Rc>, ) -> Self { ObscuraModuleLoader { base_url: base_url.to_string(), proxy_url, page_state: Some(Rc::downgrade(page_state)), standalone_client: None, + import_map, } } } @@ -72,9 +86,17 @@ impl ModuleLoader for ObscuraModuleLoader { referrer: &str, _kind: deno_core::ResolutionKind, ) -> Result { + // deno_core represents the root passed to load_side_es_module with a + // synthetic "." referrer. A browser resolves