diff --git a/moli-page-types/src/lib.rs b/moli-page-types/src/lib.rs index bca5ffbef..bd3e977b4 100644 --- a/moli-page-types/src/lib.rs +++ b/moli-page-types/src/lib.rs @@ -4385,11 +4385,16 @@ mod tests { r#"{"ok":true}"#, &expectation, )); - assert!(!json_path_equals( + assert!(json_path_equals( &[("content-type".to_owned(), b"text/json".to_vec())], r#"{"ok":true}"#, &expectation, )); + assert!(!json_path_equals( + &[("content-type".to_owned(), b"text/plain".to_vec())], + r#"{"ok":true}"#, + &expectation, + )); let regex_expectation = SubresourceJsonPathRegex { path: vec!["data".to_owned(), "url".to_owned()], diff --git a/moli-renderer-v8/src/worker/module_mime.rs b/moli-renderer-v8/src/worker/module_mime.rs index b8b34b8e5..f21cf7500 100644 --- a/moli-renderer-v8/src/worker/module_mime.rs +++ b/moli-renderer-v8/src/worker/module_mime.rs @@ -18,7 +18,13 @@ pub(crate) fn ensure_worker_wasm_module_mime( pub(crate) fn ensure_worker_json_module_mime( response: &moli_fetch::Response, ) -> Result<(), String> { - let content_type = worker_module_response_content_type(&response.headers); + ensure_worker_json_module_mime_from_headers(&response.headers) +} + +pub(super) fn ensure_worker_json_module_mime_from_headers( + headers: &[(String, String)], +) -> Result<(), String> { + let content_type = worker_module_response_content_type(headers); let Some(content_type) = content_type else { return Err( "non-JSON module response for JSON import attribute: missing Content-Type".to_owned(), diff --git a/moli-renderer-v8/src/worker/module_runtime.rs b/moli-renderer-v8/src/worker/module_runtime.rs index a5ddc9100..bb946cfd7 100644 --- a/moli-renderer-v8/src/worker/module_runtime.rs +++ b/moli-renderer-v8/src/worker/module_runtime.rs @@ -20,7 +20,7 @@ use tokio::sync::mpsc; use url::Url; use super::global_scope::worker_current_script_url; -use super::handle::{WorkerParentErrorEventKind, WorkerScriptResource}; +use super::handle::{WorkerParentErrorEventKind, WorkerScriptResource, WorkerScriptResourceKind}; use crate::content_security_policy::ContentSecurityPolicyUrlViolation; pub(super) type WorkerBootstrapError = ( @@ -279,6 +279,24 @@ impl WorkerModuleFetchedSource { } impl WorkerModuleSource { + pub(super) fn from_response_parts( + module_type: WorkerModuleType, + url: &Url, + headers: &[(String, String)], + bytes: Vec, + ) -> Result { + let kind = module_type.response_kind(url, headers, &bytes)?; + Ok(Self::from_bytes(kind, bytes)) + } + + pub(super) fn from_bytes(kind: WorkerScriptResourceKind, bytes: Vec) -> Self { + if kind == WorkerScriptResourceKind::WebAssemblyModule { + Self::binary(bytes) + } else { + Self::text(moli_encoding::decode_utf8(&bytes)) + } + } + pub(super) fn text(source: String) -> Self { Self::Text(source) } @@ -1053,12 +1071,6 @@ fn advance_worker_dynamic_module_import( let module_url = resolve_worker_module_specifier(&job.specifier, &job.base_url)?; let module_key = worker_module_key_for_attributes(&module_url, &job.attributes) .map_err(|message| format!("{message} for dynamic import `{}`", job.specifier))?; - if job.phase == ModuleImportPhase::Source && module_key.kind != WorkerModuleKind::WebAssembly { - return Err(WorkerDynamicModuleImportError::syntax_error(format!( - "source-phase dynamic import `{}` does not resolve to a WebAssembly module", - job.specifier - ))); - } if let Some(error) = graph.borrow().source_fetch_failure(&module_key) { return Err(WorkerDynamicModuleImportError::type_error(format!( "Failed to dynamically import module worker dependency `{module_url}`: {error}" @@ -1082,7 +1094,7 @@ fn advance_worker_dynamic_module_import( let inherited_referrer_policy = graph.borrow().referrer_policy_for_url(&job.base_url); let root_entry = match existing_root_entry { Some(entry) => entry, - None => match load_worker_static_module_dependency(module_url)? { + None => match load_worker_static_module_dependency(module_url, module_key.module_type)? { WorkerModuleDependencyLoad::Source { url, source } => { let key = worker_module_key_for_attributes(&url, &job.attributes).map_err(|message| { @@ -1389,23 +1401,28 @@ fn resolve_single_worker_dynamic_module_import( match job.phase { ModuleImportPhase::Evaluation => { let module = v8::Local::new(scope, &record.module); - Some(module.get_module_namespace()) - } - ModuleImportPhase::Source => { - let Some(wasm_record) = record.wasm_module.as_ref() else { - reject_worker_dynamic_module_import( - scope, - job, - WorkerDynamicModuleImportError::syntax_error( - "source-phase dynamic import did not resolve to a WebAssembly module", - ), - ); - return; - }; - wasm_record - .source_module(scope) - .map(v8::Local::::from) + Ok(Some(module.get_module_namespace())) } + ModuleImportPhase::Source => record + .wasm_module + .as_ref() + .ok_or_else(|| { + WorkerDynamicModuleImportError::syntax_error( + "source-phase dynamic import did not resolve to a WebAssembly module", + ) + }) + .map(|wasm_record| { + wasm_record + .source_module(scope) + .map(v8::Local::::from) + }), + } + }; + let resolved_value = match resolved_value { + Ok(value) => value, + Err(error) => { + reject_worker_dynamic_module_import(scope, job, error); + return; } }; let Some(resolved_value) = resolved_value else { @@ -1508,7 +1525,7 @@ impl WorkerModuleBootstrapJob { } fn advance(&mut self, scope: &mut v8::PinScope<'_, '_>) -> WorkerModuleAdvance { - let root_key = worker_module_root_key_for_source(&self.root_url, &self.source); + let root_key = WorkerModuleKey::javascript_or_wasm(self.root_url.clone()); let root_referrer_policy = self .runtime .graph @@ -1591,7 +1608,7 @@ impl WorkerModuleBootstrapJob { if let Err(error) = self.finish_fetch(scope, request, completion) { return WorkerModuleAdvance::Failed(error); } - let root_key = worker_module_root_key_for_source(&self.root_url, &self.source); + let root_key = WorkerModuleKey::javascript_or_wasm(self.root_url.clone()); let root_entry = match self.runtime.graph.borrow().entry_for_key(&root_key) { Some(entry) => entry, None => { @@ -1623,13 +1640,6 @@ impl WorkerModuleBootstrapJob { } } -fn worker_module_root_key_for_source(url: &Url, source: &WorkerModuleSource) -> WorkerModuleKey { - match source { - WorkerModuleSource::Text(_) => WorkerModuleKey::java_script(url.clone()), - WorkerModuleSource::Binary(_) => WorkerModuleKey::webassembly(url.clone()), - } -} - enum WorkerModuleAdvance { ReadyToInstantiate { root_entry: usize }, NeedFetches(WorkerModuleGraphFetchBatch), @@ -1754,12 +1764,8 @@ impl WorkerModuleGraphFetchRequest { self.csp_source } - pub(super) fn module_type(&self) -> Option<&str> { - self.attributes.module_type() - } - - pub(super) fn kind(&self) -> WorkerModuleKind { - self.key.kind + pub(super) fn module_type(&self) -> WorkerModuleType { + self.key.module_type } pub(super) fn credentials_mode(&self) -> RequestCredentialsMode { @@ -1771,7 +1777,7 @@ impl WorkerModuleGraphFetchRequest { } pub(super) fn browser_request_metadata(&self) -> BrowserRequestMetadata { - worker_module_browser_request_metadata(self.key.kind, self.browser_request_metadata) + worker_module_browser_request_metadata(self.key.module_type, self.browser_request_metadata) } fn graph_browser_request_metadata(&self) -> BrowserRequestMetadata { @@ -1780,12 +1786,12 @@ impl WorkerModuleGraphFetchRequest { } fn worker_module_browser_request_metadata( - kind: WorkerModuleKind, + module_type: WorkerModuleType, graph_metadata: BrowserRequestMetadata, ) -> BrowserRequestMetadata { - match kind { - WorkerModuleKind::Json => BrowserRequestMetadata::JsonModule, - WorkerModuleKind::JavaScript | WorkerModuleKind::WebAssembly => graph_metadata, + match module_type { + WorkerModuleType::Json => BrowserRequestMetadata::JsonModule, + WorkerModuleType::JavaScriptOrWebAssembly => graph_metadata, } } @@ -1812,45 +1818,64 @@ struct WorkerModuleRecord { #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct WorkerModuleKey { url: Url, - kind: WorkerModuleKind, + module_type: WorkerModuleType, attributes: ModuleAttributesKey, } impl WorkerModuleKey { - fn java_script(url: Url) -> Self { - Self::java_script_with_attributes(url, ModuleAttributesKey::empty()) - } - - fn java_script_with_attributes(url: Url, attributes: ModuleAttributesKey) -> Self { + fn javascript_or_wasm(url: Url) -> Self { Self { url, - kind: WorkerModuleKind::JavaScript, - attributes, + module_type: WorkerModuleType::JavaScriptOrWebAssembly, + attributes: ModuleAttributesKey::empty(), } } fn json(url: Url, attributes: ModuleAttributesKey) -> Self { Self { url, - kind: WorkerModuleKind::Json, + module_type: WorkerModuleType::Json, attributes, } } - - fn webassembly(url: Url) -> Self { - Self { - url, - kind: WorkerModuleKind::WebAssembly, - attributes: ModuleAttributesKey::empty(), - } - } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub(super) enum WorkerModuleKind { - JavaScript, +pub(super) enum WorkerModuleType { + // HTML keys the module map by the requested type. JavaScript and Wasm + // share this entry; the fetched MIME type selects the compiled record. + JavaScriptOrWebAssembly, Json, - WebAssembly, +} + +impl WorkerModuleType { + pub(super) fn response_kind( + self, + url: &Url, + headers: &[(String, String)], + bytes: &[u8], + ) -> Result { + match self { + Self::Json => { + super::module_mime::ensure_worker_json_module_mime_from_headers(headers)?; + Ok(WorkerScriptResourceKind::JsonModule) + } + Self::JavaScriptOrWebAssembly => { + if super::worker_response_has_webassembly_mime(headers) { + return Ok(WorkerScriptResourceKind::WebAssemblyModule); + } + super::ensure_worker_script_mime_acceptable(url, headers, bytes).map_err( + |error| { + error.replace( + "unsupported script MIME type", + "unsupported module script MIME type", + ) + }, + )?; + Ok(WorkerScriptResourceKind::JavaScript) + } + } + } } #[derive(Clone)] @@ -2051,16 +2076,15 @@ impl WorkerModuleGraph { ) -> Option { let entry = self.entry_for_module(module)?; let record = &self.records[entry]; - match record.key.kind { - WorkerModuleKind::Json => record + if let Some(wasm) = &record.wasm_module { + return Some(WorkerSyntheticModuleRecord::WebAssembly(wasm.clone())); + } + match record.key.module_type { + WorkerModuleType::Json => record .source .text_source() .map(|source| WorkerSyntheticModuleRecord::Json(source.to_owned())), - WorkerModuleKind::WebAssembly => record - .wasm_module - .clone() - .map(WorkerSyntheticModuleRecord::WebAssembly), - WorkerModuleKind::JavaScript => None, + WorkerModuleType::JavaScriptOrWebAssembly => None, } } @@ -2232,100 +2256,93 @@ fn resolve_worker_module_dependency( request: &WorkerModuleRequest, pending_keys: &mut HashSet, ) -> WorkerModuleBootstrapResult { - let dependency_url = - resolve_worker_module_specifier(&request.specifier, url).map_err(|message| { + let dependency_url = resolve_worker_module_specifier(&request.specifier, url).map_err(|error| { Box::new(worker_bootstrap_error( - scope, - url.as_str(), - &message, - WorkerParentErrorEventKind::Event, - )) - })?; - if request.phase == ModuleImportPhase::Source { - let dependency_key = worker_module_key_for_attributes(&dependency_url, &request.attributes) - .map_err(|message| { - Box::new(worker_bootstrap_error( - scope, - url.as_str(), - &format!("{message} for import `{}`", request.specifier), - WorkerParentErrorEventKind::Event, - )) - })?; - if dependency_key.kind != WorkerModuleKind::WebAssembly { - return Err(Box::new(worker_bootstrap_error( scope, url.as_str(), &format!( - "source-phase import `{}` does not resolve to a WebAssembly module", + "Failed to resolve module worker dependency `{}`: {error}", request.specifier ), WorkerParentErrorEventKind::Event, - ))); - } - } - let (dependency_key, dependency_source) = - match load_worker_static_module_dependency(dependency_url).map_err(|message| { + )) + })?; + let dependency_key = worker_module_key_for_attributes(&dependency_url, &request.attributes) + .map_err(|message| { Box::new(worker_bootstrap_error( scope, url.as_str(), - &message, + &format!("{message} for import `{}`", request.specifier), WorkerParentErrorEventKind::Event, )) - })? { - WorkerModuleDependencyLoad::Source { url, source } => { - let dependency_key = worker_module_key_for_attributes(&url, &request.attributes) - .map_err(|message| { + })?; + let (dependency_key, dependency_source) = match load_worker_static_module_dependency( + dependency_url, + dependency_key.module_type, + ) + .map_err(|message| { + Box::new(worker_bootstrap_error( + scope, + url.as_str(), + &message, + WorkerParentErrorEventKind::Event, + )) + })? { + WorkerModuleDependencyLoad::Source { url, source } => { + let dependency_key = worker_module_key_for_attributes(&url, &request.attributes) + .map_err(|message| { + Box::new(worker_bootstrap_error( + scope, + url.as_str(), + &format!("{message} for import `{}`", request.specifier), + WorkerParentErrorEventKind::Event, + )) + })?; + (dependency_key, source) + } + WorkerModuleDependencyLoad::NeedFetch(dependency_url) => { + let dependency_key = + worker_module_key_for_attributes(&dependency_url, &request.attributes).map_err( + |message| { Box::new(worker_bootstrap_error( scope, url.as_str(), &format!("{message} for import `{}`", request.specifier), WorkerParentErrorEventKind::Event, )) - })?; - (dependency_key, source) + }, + )?; + let existing_entry = graph.borrow().entry_for_key(&dependency_key); + if let Some(target_entry) = existing_entry { + graph.borrow_mut().add_dependency( + entry, + request.specifier.clone(), + request.attributes.clone(), + target_entry, + ); + return Ok(WorkerModuleGraphBuild::Ready); } - WorkerModuleDependencyLoad::NeedFetch(dependency_url) => { - let dependency_key = - worker_module_key_for_attributes(&dependency_url, &request.attributes) - .map_err(|message| { - Box::new(worker_bootstrap_error( - scope, - url.as_str(), - &format!("{message} for import `{}`", request.specifier), - WorkerParentErrorEventKind::Event, - )) - })?; - let existing_entry = graph.borrow().entry_for_key(&dependency_key); - if let Some(target_entry) = existing_entry { - graph.borrow_mut().add_dependency( - entry, - request.specifier.clone(), - request.attributes.clone(), - target_entry, - ); - return Ok(WorkerModuleGraphBuild::Ready); - } - if !pending_keys.insert(dependency_key.clone()) { - return Ok(WorkerModuleGraphBuild::Ready); - } - let fetch_id = reserve_worker_module_graph_fetch_id(scope); - let referrer_policy = graph.borrow().referrer_policy(entry).map(str::to_owned); - return Ok(WorkerModuleGraphBuild::NeedFetches( - WorkerModuleGraphFetchBatch::single(WorkerModuleGraphFetchRequest::new( - fetch_id, - dependency_key, - fetch_initiator_url.clone(), - csp_source, - Some(entry), - request.specifier.clone(), - request.attributes.clone(), - graph.borrow().credentials_mode(), - referrer_policy, - browser_request_metadata, - )), - )); + if !pending_keys.insert(dependency_key.clone()) { + return Ok(WorkerModuleGraphBuild::Ready); } - }; + let fetch_id = reserve_worker_module_graph_fetch_id(scope); + let referrer_policy = graph.borrow().referrer_policy(entry).map(str::to_owned); + return Ok(WorkerModuleGraphBuild::NeedFetches( + WorkerModuleGraphFetchBatch::single(WorkerModuleGraphFetchRequest::new( + fetch_id, + dependency_key, + fetch_initiator_url.clone(), + csp_source, + Some(entry), + request.specifier.clone(), + request.attributes.clone(), + graph.borrow().credentials_mode(), + referrer_policy, + browser_request_metadata, + )), + )); + } + }; let referrer_policy = graph.borrow().referrer_policy(entry).map(str::to_owned); let target_entry = ensure_worker_module_entry( scope, @@ -2429,18 +2446,10 @@ fn compile_worker_module_record( Vec, Option, )> { - if matches!(key.kind, WorkerModuleKind::Json) { + if key.module_type == WorkerModuleType::Json { return compile_worker_synthetic_module_record(scope, source_url); } - if key.kind == WorkerModuleKind::WebAssembly { - let Some(bytes) = source.binary_source() else { - return Err(Box::new(worker_bootstrap_error( - scope, - source_url.as_str(), - "WebAssembly module worker source is not binary", - WorkerParentErrorEventKind::Event, - ))); - }; + if let Some(bytes) = source.binary_source() { return compile_worker_wasm_module_record(scope, bytes, source_url); } let Some(source) = source.text_source() else { @@ -2668,16 +2677,21 @@ fn resolve_worker_module_specifier(specifier: &str, base_url: &Url) -> Result Result { match dependency_url.scheme() { "data" => { - let source = super::decode_data_url_script_source( + let (bytes, mime) = moli_web_mime::data_url_body_and_mime_type(dependency_url.as_str()) + .ok_or_else(|| format!("Failed to load module worker dependency: invalid data URL `{dependency_url}`"))?; + let source = WorkerModuleSource::from_response_parts( + module_type, &dependency_url, - "Failed to load module worker dependency", + &[("content-type".to_owned(), mime)], + bytes, )?; Ok(WorkerModuleDependencyLoad::Source { url: dependency_url, - source: WorkerModuleSource::text(source), + source, }) } "http" | "https" => Ok(WorkerModuleDependencyLoad::NeedFetch(dependency_url)), @@ -2692,10 +2706,7 @@ fn worker_module_key_for_attributes( attributes: &ModuleAttributesKey, ) -> Result { let Some(module_type) = attributes.module_type() else { - if url.path().to_ascii_lowercase().ends_with(".wasm") { - return Ok(WorkerModuleKey::webassembly(url.clone())); - } - return Ok(WorkerModuleKey::java_script(url.clone())); + return Ok(WorkerModuleKey::javascript_or_wasm(url.clone())); }; match module_type { "json" => Ok(WorkerModuleKey::json(url.clone(), attributes.clone())), diff --git a/moli-renderer-v8/src/worker/thread/mod.rs b/moli-renderer-v8/src/worker/thread/mod.rs index 1e28aff33..347e0ae49 100644 --- a/moli-renderer-v8/src/worker/thread/mod.rs +++ b/moli-renderer-v8/src/worker/thread/mod.rs @@ -98,8 +98,7 @@ use super::handle::{ WorkerBootstrapCompletion, WorkerBootstrapFailure, WorkerBootstrapSuccess, WorkerDevToolsHandle, WorkerErrorPhase, WorkerErrorSource, WorkerFetchHandlerType, WorkerHandle, WorkerMessage, WorkerNetworkPolicy, WorkerParentErrorEventKind, - WorkerRuntimeInspectorMessageBatch, WorkerScriptResource, WorkerScriptResourceKind, - WorkerToParentMessage, + WorkerRuntimeInspectorMessageBatch, WorkerScriptResource, WorkerToParentMessage, }; use super::inspector_task_runner::{ WorkerInspectorTask, WorkerInspectorTaskMode, WorkerInspectorTaskRunner, @@ -108,12 +107,11 @@ use super::module_runtime::{ WorkerBootstrapError, WorkerDynamicModuleImportAdvance, WorkerModuleBootstrapResume, WorkerModuleBootstrapStart, WorkerModuleEvaluationCompletion, WorkerModuleFetchedSource, WorkerModuleGraphFetchBatch, WorkerModuleGraphFetchCompletion, WorkerModuleGraphFetchCspSource, - WorkerModuleGraphFetchRequest, WorkerModuleKind, WorkerModulePendingBootstrap, - WorkerModuleSource, evaluate_module_worker_bootstrap_source, - install_classic_worker_dynamic_module_runtime, resume_worker_dynamic_module_evaluation, - resume_worker_dynamic_module_fetch, run_next_worker_dynamic_module_import, - worker_dynamic_module_import_waits_for_fetch, worker_has_pending_dynamic_module_imports, - worker_has_runnable_dynamic_module_imports, + WorkerModuleGraphFetchRequest, WorkerModulePendingBootstrap, WorkerModuleSource, + evaluate_module_worker_bootstrap_source, install_classic_worker_dynamic_module_runtime, + resume_worker_dynamic_module_evaluation, resume_worker_dynamic_module_fetch, + run_next_worker_dynamic_module_import, worker_dynamic_module_import_waits_for_fetch, + worker_has_pending_dynamic_module_imports, worker_has_runnable_dynamic_module_imports, }; pub(super) type WorkerExceptionError = Box<(V8ExceptionReport, Option>)>; @@ -698,8 +696,7 @@ fn start_worker_module_graph_fetch( .with_credentials_mode(request.credentials_mode()); let requested_url = request.url().clone(); let request_initiator_url = request.initiator_url().clone(); - let requested_module_type = request.module_type().map(str::to_owned); - let requested_kind = request.kind(); + let requested_module_type = request.module_type(); let request_credentials_mode = request.credentials_mode(); let response_policy = policy.clone(); let response_resource_kind = resource_kind; @@ -753,24 +750,11 @@ fn start_worker_module_graph_fetch( request_credentials_mode, Default::default(), )?; - if requested_kind == WorkerModuleKind::WebAssembly { - crate::worker::ensure_worker_wasm_module_mime(&response)?; - } else { - match requested_module_type.as_deref() { - Some("json") => crate::worker::ensure_worker_json_module_mime(&response)?, - _ => crate::worker::ensure_worker_script_mime_acceptable( - &response.final_url, - &response.headers, - response.body_bytes(), - ) - .map_err(|error| { - error.replace( - "unsupported script MIME type", - "unsupported module script MIME type", - ) - })?, - } - } + let response_kind = requested_module_type.response_kind( + &response.final_url, + &response.headers, + response.body_bytes(), + )?; let response_time_ms = response_started_at .elapsed() .as_millis() @@ -784,13 +768,9 @@ fn start_worker_module_graph_fetch( &body_bytes, response_time_ms, ) - .with_kind(worker_script_resource_kind_for_module(requested_kind)); + .with_kind(response_kind); let final_url = head.final_url.clone(); - let source = if requested_kind == WorkerModuleKind::WebAssembly { - WorkerModuleSource::binary(body_bytes) - } else { - WorkerModuleSource::text(moli_encoding::decode_utf8(&body_bytes)) - }; + let source = WorkerModuleSource::from_bytes(response_kind, body_bytes); Ok(WorkerModuleFetchedSource::new(final_url, source) .with_resource(resource) .with_response_referrer_policy(response_referrer_policy)) @@ -861,14 +841,6 @@ fn start_worker_module_graph_fetch_batch( } } -fn worker_script_resource_kind_for_module(kind: WorkerModuleKind) -> WorkerScriptResourceKind { - match kind { - WorkerModuleKind::JavaScript => WorkerScriptResourceKind::JavaScript, - WorkerModuleKind::Json => WorkerScriptResourceKind::JsonModule, - WorkerModuleKind::WebAssembly => WorkerScriptResourceKind::WebAssemblyModule, - } -} - fn drain_worker_file_reader_queue( isolate: &mut v8::OwnedIsolate, context: &v8::Global, diff --git a/moli-renderer-v8/src/worker/thread/tests/modules.rs b/moli-renderer-v8/src/worker/thread/tests/modules.rs index b289b7ee8..f772d4221 100644 --- a/moli-renderer-v8/src/worker/thread/tests/modules.rs +++ b/moli-renderer-v8/src/worker/thread/tests/modules.rs @@ -5,6 +5,7 @@ use crate::worker::handle::WorkerParentErrorEventKind; use crate::worker::{WorkerErrorPhase, WorkerScriptResourceKind}; use moli_crypto::sha256_hex; +mod response_mime; mod source_phase; const WORKER_WASM_IMPORT_PM: &[u8] = &[ diff --git a/moli-renderer-v8/src/worker/thread/tests/modules/response_mime.rs b/moli-renderer-v8/src/worker/thread/tests/modules/response_mime.rs new file mode 100644 index 000000000..584a25d58 --- /dev/null +++ b/moli-renderer-v8/src/worker/thread/tests/modules/response_mime.rs @@ -0,0 +1,375 @@ +use super::source_phase::ModuleSourceServer; +use super::*; + +// Exports answer() -> 42 and a custom section "x" containing the byte 0xff. +const WASM_WITH_NON_UTF8_SECTION: &[u8] = &[ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, 0x03, + 0x02, 0x01, 0x00, 0x07, 0x0a, 0x01, 0x06, 0x61, 0x6e, 0x73, 0x77, 0x65, 0x72, 0x00, 0x00, 0x0a, + 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b, 0x00, 0x03, 0x01, 0x78, 0xff, +]; + +fn module_data_url(mime: &str, bytes: &[u8]) -> String { + let body = bytes + .iter() + .map(|byte| format!("%{byte:02X}")) + .collect::(); + format!("data:{mime},{body}") +} + +#[tokio::test] +async fn worker_wasm_imports_use_response_mime_independently_of_url_suffix() { + ensure_v8(); + for name in ["module.wasm", "module.js", "module"] { + for phase in ["static-source", "dynamic-source", "evaluation"] { + let mut server = ModuleSourceServer::start().await; + let url = serde_json::to_string(&format!("./{name}")).unwrap(); + let source = match phase { + "static-source" => format!("import source wasm from {url};"), + "dynamic-source" => format!("const wasm = await import.source({url});"), + _ => format!( + "const namespace = await import({url}); postMessage(namespace.answer());" + ), + }; + let source = if phase == "evaluation" { + source + } else { + format!( + r#"{source} + postMessage([new WebAssembly.Instance(wasm).exports.answer(), + new Uint8Array(WebAssembly.Module.customSections(wasm, 'x')[0])[0]]); + "# + ) + }; + let mut handle = server.worker(source, WorkerScriptKind::Module); + server + .respond_bytes( + &format!("/worker/{name}"), + "200 OK", + "Application/Wasm; charset=UTF-8", + WASM_WITH_NON_UTF8_SECTION, + ) + .await; + let expected = if phase == "evaluation" { + "42" + } else { + "[42,255]" + }; + assert_eq!( + recv_post_json(&mut handle).await, + expected, + "{name}: {phase}" + ); + handle.terminate_and_join(); + server.assert_no_more_requests(); + } + } +} + +#[tokio::test] +async fn worker_javascript_at_wasm_url_is_not_poisoned_by_source_import() { + ensure_v8(); + for source_first in [true, false] { + let mut server = ModuleSourceServer::start().await; + let imports = if source_first { + "[loadSource(), loadEvaluation()]" + } else { + "[loadEvaluation(), loadSource()]" + }; + let mut handle = server.worker(format!(r#" + const loadSource = () => import.source('./module.wasm').then(() => 'unexpected', e => e.name); + const loadEvaluation = () => import('./module.wasm').then(m => m.answer); + const results = await Promise.all({imports}); + postMessage([results, await loadSource(), self.runs]); + "#), WorkerScriptKind::Module); + server + .respond( + "/worker/module.wasm", + "200 OK", + "text/javascript", + "self.runs = (self.runs || 0) + 1; export const answer = 42;", + ) + .await; + let expected = if source_first { + r#"[["SyntaxError",42],"SyntaxError",1]"# + } else { + r#"[[42,"SyntaxError"],"SyntaxError",1]"# + }; + assert_eq!(recv_post_json(&mut handle).await, expected); + handle.terminate_and_join(); + server.assert_no_more_requests(); + } +} + +#[tokio::test] +async fn worker_static_source_import_rejects_javascript_without_evaluating_it() { + ensure_v8(); + let mut server = ModuleSourceServer::start().await; + let mut handle = server.worker( + "import source source from './module.wasm'; postMessage('unexpected root');".into(), + WorkerScriptKind::Module, + ); + server + .respond( + "/worker/module.wasm", + "200 OK", + "text/javascript", + "postMessage('unexpected dependency');", + ) + .await; + match timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap() { + WorkerToParentMessage::Error { message, .. } => { + assert!(message.contains("WebAssembly"), "{message}") + } + other => panic!("expected source-phase rejection before execution, got {other:?}"), + } + handle.terminate_and_join(); + server.assert_no_more_requests(); +} + +#[tokio::test] +async fn worker_data_wasm_imports_preserve_bytes_and_share_source_with_evaluation() { + ensure_v8(); + let data_url = module_data_url("application/wasm", WASM_WITH_NON_UTF8_SECTION); + let data_url = serde_json::to_string(&data_url).unwrap(); + let mut handle = spawn_test_worker_with_options( + WorkerSpawnOptions::new( + format!( + r#" + import source wasm from {data_url}; + const again = await import.source({data_url}); + const namespace = await import({data_url}); + postMessage([wasm === again, namespace.answer(), + new Uint8Array(WebAssembly.Module.customSections(wasm, 'x')[0])[0]]); + "# + ), + "https://example.test/worker/main.js".into(), + ) + .with_script_kind(WorkerScriptKind::Module), + ); + assert_eq!(recv_post_json(&mut handle).await, "[true,42,255]"); + handle.terminate_and_join(); +} + +#[tokio::test] +async fn worker_data_javascript_and_json_modules_use_utf8_decoding() { + ensure_v8(); + let js = module_data_url( + "text/javascript;charset=windows-1252", + b"\xef\xbb\xbfexport const text = 'a\xffb';", + ); + let json = module_data_url( + "application/json;charset=windows-1252", + b"\xef\xbb\xbf{\"text\":\"a\xffb\"}", + ); + let mut handle = spawn_test_worker_with_options( + WorkerSpawnOptions::new( + format!( + r#" + const js = await import({}); + const json = await import({}, {{with: {{type: 'json'}}}}); + postMessage([js.text, json.default.text]); + "#, + serde_json::to_string(&js).unwrap(), + serde_json::to_string(&json).unwrap() + ), + "https://example.test/worker/main.js".into(), + ) + .with_script_kind(WorkerScriptKind::Module), + ); + assert_eq!(recv_post_json(&mut handle).await, r#"["a�b","a�b"]"#); + handle.terminate_and_join(); +} + +#[tokio::test] +async fn worker_data_modules_enforce_mime_and_json_import_attributes() { + ensure_v8(); + for (mime, json_attribute, body, expected) in [ + ("text/javascript", false, "export const answer = 42;", "42"), + ( + "text/plain", + false, + "export const answer = 42;", + r#""TypeError""#, + ), + ("", false, "export const answer = 42;", r#""TypeError""#), + ("application/json", true, r#"{"answer":42}"#, "42"), + ("Text/JSON; charset=UTF-8", true, r#"{"answer":42}"#, "42"), + ("application/manifest+json", true, r#"{"answer":42}"#, "42"), + ("text/plain", true, r#"{"answer":42}"#, r#""TypeError""#), + ( + "text/javascript", + true, + r#"{"answer":42}"#, + r#""TypeError""#, + ), + ( + "application/json", + false, + r#"{"answer":42}"#, + r#""TypeError""#, + ), + ] { + let url = serde_json::to_string(&module_data_url(mime, body.as_bytes())).unwrap(); + let options = if json_attribute { + ", {with: {type: 'json'}}" + } else { + "" + }; + let result = if json_attribute { + "m.default.answer" + } else { + "m.answer" + }; + let mut handle = spawn_test_worker_with_options( + WorkerSpawnOptions::new(format!("import({url}{options}).then(m => postMessage({result}), e => postMessage(e.name));"), + "https://example.test/worker/main.js".into()).with_script_kind(WorkerScriptKind::Module), + ); + assert_eq!( + recv_post_json(&mut handle).await, + expected, + "{mime}, JSON: {json_attribute}" + ); + handle.terminate_and_join(); + } +} + +#[tokio::test] +async fn worker_json_imports_accept_text_json_response_mime() { + ensure_v8(); + for source in [ + "import data from './module.json' with {type: 'json'}; postMessage(data.answer);", + "const {default: data} = await import('./module.json', {with: {type: 'json'}}); postMessage(data.answer);", + ] { + let mut server = ModuleSourceServer::start().await; + let mut handle = server.worker(source.into(), WorkerScriptKind::Module); + server + .respond( + "/worker/module.json", + "200 OK", + "Text/JSON; charset=UTF-8", + r#"{"answer":42}"#, + ) + .await; + assert_eq!(recv_post_json(&mut handle).await, "42"); + handle.terminate_and_join(); + server.assert_no_more_requests(); + } +} + +#[tokio::test] +async fn worker_module_map_separates_json_from_javascript_or_wasm() { + ensure_v8(); + for wasm in [true, false] { + let mut server = ModuleSourceServer::start().await; + let (wrong, right, mime, body) = if wasm { + ( + "import('./module', {with: {type: 'json'}})", + "import.source('./module').then(m => new WebAssembly.Instance(m).exports.answer())", + "application/wasm", + WASM_WITH_NON_UTF8_SECTION, + ) + } else { + ( + "import('./module')", + "import('./module', {with: {type: 'json'}}).then(m => m.default.answer)", + "application/json", + br#"{"answer":42}"#.as_slice(), + ) + }; + let mut handle = server.worker( + format!( + r#" + const error = await {wrong}.then(() => 'unexpected', e => e.name); + const first = await {right}; + const again = await {right}; + postMessage([error, first, again]); + "# + ), + WorkerScriptKind::Module, + ); + for _ in 0..2 { + server + .respond_bytes("/worker/module", "200 OK", mime, body) + .await; + } + assert_eq!(recv_post_json(&mut handle).await, r#"["TypeError",42,42]"#); + handle.terminate_and_join(); + server.assert_no_more_requests(); + } +} + +#[tokio::test] +async fn worker_javascript_root_at_wasm_url_reuses_the_module_map_entry() { + ensure_v8(); + let mut server = ModuleSourceServer::start().await; + let mut handle = server.worker_at(r#" + export const answer = 42; + onmessage = () => import(import.meta.url).then(m => postMessage(m.answer), e => postMessage(e.name)); + postMessage('ready'); + "#.into(), WorkerScriptKind::Module, "main.wasm"); + assert_eq!(recv_post_json(&mut handle).await, r#""ready""#); + handle.post_message(serialize_test_string("import root")); + assert_eq!(recv_post_json(&mut handle).await, "42"); + handle.terminate_and_join(); + server.assert_no_more_requests(); +} + +#[tokio::test] +async fn worker_wasm_root_without_wasm_suffix_can_be_imported_as_source() { + ensure_v8(); + let (base_url, server) = spawn_path_response_http_server(vec![( + "/worker/worker-helper.js", "HTTP/1.1 200 OK", "text/javascript", + r#"export function pm(value) { + import.source('./main.bin').then(source => postMessage([value, source instanceof WebAssembly.Module]), + error => postMessage({error: error.name})); + }"#.into(), Duration::ZERO, + )]).await; + let mut handle = spawn_worker_with_source_and_kind_and_network_policy( + WorkerScriptSource::binary(WORKER_WASM_IMPORT_PM.to_vec()), + format!("{base_url}/worker/main.bin"), + worker_test_request_client(), + WorkerScriptKind::Module, + WorkerNetworkPolicy::default(), + ); + assert_eq!(recv_post_json(&mut handle).await, "[42,true]"); + handle.terminate_and_join(); + server.await.unwrap(); +} + +#[tokio::test] +async fn service_worker_source_import_records_the_actual_wasm_resource_kind() { + ensure_v8(); + let mut server = ModuleSourceServer::start().await; + let mut handle = spawn_test_worker_with_options(service_worker_module_options( + "import source wasm from './module.js'; if (!(wasm instanceof WebAssembly.Module)) throw new Error('not wasm'); skipWaiting();".into(), + format!("{}/worker/sw.js", server.url), + )); + server + .respond_bytes( + "/worker/module.js", + "200 OK", + "application/wasm", + WASM_WITH_NON_UTF8_SECTION, + ) + .await; + let mut resource = None; + let mut ready = false; + while resource.is_none() || !ready { + match timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap() { + WorkerToParentMessage::ServiceWorkerImportedScriptLoaded { + resource: loaded, .. + } => resource = Some(loaded), + WorkerToParentMessage::ServiceWorkerSkipWaiting { .. } => ready = true, + WorkerToParentMessage::Error { message, .. } => { + panic!("unexpected bootstrap error: {message}") + } + _ => {} + } + } + let resource = resource.unwrap(); + assert_eq!(resource.kind, WorkerScriptResourceKind::WebAssemblyModule); + assert_eq!(resource.body_len, WASM_WITH_NON_UTF8_SECTION.len()); + assert_eq!(resource.body_sha256, sha256_hex(WASM_WITH_NON_UTF8_SECTION)); + handle.terminate_and_join(); + server.assert_no_more_requests(); +} diff --git a/moli-renderer-v8/src/worker/thread/tests/modules/source_phase.rs b/moli-renderer-v8/src/worker/thread/tests/modules/source_phase.rs index 2d472b5cf..a13756694 100644 --- a/moli-renderer-v8/src/worker/thread/tests/modules/source_phase.rs +++ b/moli-renderer-v8/src/worker/thread/tests/modules/source_phase.rs @@ -3,14 +3,14 @@ use tokio::net::TcpStream; // Keep responses under the test's control: a source import must settle even // while the evaluation graph's dependency response is withheld. -struct ModuleSourceServer { - url: String, +pub(super) struct ModuleSourceServer { + pub(super) url: String, requests: tokio::sync::mpsc::UnboundedReceiver<(String, TcpStream)>, task: JoinHandle<()>, } impl ModuleSourceServer { - async fn start() -> Self { + pub(super) async fn start() -> Self { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let (sender, requests) = tokio::sync::mpsc::unbounded_channel(); @@ -37,16 +37,25 @@ impl ModuleSourceServer { } } - fn worker(&self, source: String, kind: WorkerScriptKind) -> WorkerTestHandle { + pub(super) fn worker(&self, source: String, kind: WorkerScriptKind) -> WorkerTestHandle { + self.worker_at(source, kind, "main.js") + } + + pub(super) fn worker_at( + &self, + source: String, + kind: WorkerScriptKind, + name: &str, + ) -> WorkerTestHandle { spawn_worker_with_request_client_and_kind( source, - format!("{}/worker/main.js", self.url), + format!("{}/worker/{name}", self.url), worker_test_request_client(), kind, ) } - async fn request(&mut self, path: &str) -> TcpStream { + pub(super) async fn request(&mut self, path: &str) -> TcpStream { let (actual, stream) = timeout(TIMEOUT, self.requests.recv()) .await .unwrap() @@ -55,16 +64,31 @@ impl ModuleSourceServer { stream } - async fn respond(&mut self, path: &str, status: &str, mime: &str, body: &str) { + pub(super) async fn respond(&mut self, path: &str, status: &str, mime: &str, body: &str) { Self::write(self.request(path).await, status, mime, body).await; } - async fn write(mut stream: TcpStream, status: &str, mime: &str, body: &str) { - let response = format!( - "HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + pub(super) async fn respond_bytes( + &mut self, + path: &str, + status: &str, + mime: &str, + body: &[u8], + ) { + Self::write_bytes(self.request(path).await, status, mime, body).await; + } + + async fn write(stream: TcpStream, status: &str, mime: &str, body: &str) { + Self::write_bytes(stream, status, mime, body.as_bytes()).await; + } + + async fn write_bytes(mut stream: TcpStream, status: &str, mime: &str, body: &[u8]) { + let head = format!( + "HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); - stream.write_all(response.as_bytes()).await.unwrap(); + stream.write_all(head.as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); } async fn wasm(&mut self) { @@ -77,7 +101,7 @@ impl ModuleSourceServer { .await; } - fn assert_no_more_requests(&mut self) { + pub(super) fn assert_no_more_requests(&mut self) { assert!(self.requests.try_recv().is_err(), "unexpected module fetch"); } } diff --git a/moli-web-mime/src/classification.rs b/moli-web-mime/src/classification.rs index 3ff3a0dd5..a4a627e9d 100644 --- a/moli-web-mime/src/classification.rs +++ b/moli-web-mime/src/classification.rs @@ -132,7 +132,9 @@ pub fn multipart_form_data_boundary(input: &str) -> Option { } pub fn is_json_module_mime(input: &str) -> bool { - mime_essence(input).is_some_and(|mime| mime == "application/json" || mime.ends_with("+json")) + mime_essence(input).is_some_and(|mime| { + matches!(mime.as_str(), "application/json" | "text/json") || mime.ends_with("+json") + }) } pub fn is_webassembly_mime(input: &str) -> bool { diff --git a/moli-web-mime/src/tests.rs b/moli-web-mime/src/tests.rs index b2c49be9d..047d3d2a9 100644 --- a/moli-web-mime/src/tests.rs +++ b/moli-web-mime/src/tests.rs @@ -267,7 +267,10 @@ fn matches_script_and_form_content_types() { assert!(is_json_module_mime("Application/JSON; charset=utf-8")); assert!(is_json_module_mime("application/manifest+json")); - assert!(!is_json_module_mime("text/json")); + assert!(is_json_module_mime("text/json")); + assert!(is_json_module_mime("Text/JSON; charset=utf-8")); + assert!(!is_json_module_mime("text/json+blah")); + assert!(!is_json_module_mime("image/json")); assert!(is_media_source_type_supported( "video/mp4; codecs=\"avc1.42E01E\"" diff --git a/moli-wpt-compat/fixtures/wpt/ported/worker/worker-module-json-import-error-basic.html b/moli-wpt-compat/fixtures/wpt/ported/worker/worker-module-json-import-error-basic.html index dee44b167..e49f8fefb 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/worker/worker-module-json-import-error-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/worker/worker-module-json-import-error-basic.html @@ -76,7 +76,7 @@ promise_test(async function () { "import value from " + JSON.stringify(jsUrl) + " with { type: 'json' };", "postMessage(value);", ].join("\n"), - ["syntax"], + ["non-JSON module response", "JSON import attribute", "text/javascript"], "static non-JSON import with JSON type", );