orinium_browser/browser/core/
resource_loader.rs1use crate::engine::origin::Origin;
7use crate::platform::network::{NetworkCore, NetworkError, NetworkRequest, StatusCode};
8use anyhow::{Context, Result, anyhow};
9use base64::Engine;
10use std::{fmt, rc::Rc};
11use url::Url;
12
13fn scheme_allowed(initiator: &Origin, url: &Url) -> bool {
19 match url.scheme() {
20 "http" | "https" | "data" => true,
21 _ => initiator.is_opaque(),
22 }
23}
24
25pub struct BrowserResourceLoader {
68 pub network: Option<Rc<NetworkCore>>,
70
71 pub immediate_pool: Vec<BrowserNetworkMessage>,
75}
76
77impl BrowserResourceLoader {
78 pub fn new(network: Option<Rc<NetworkCore>>) -> Self {
84 Self {
85 network,
86 immediate_pool: vec![],
87 }
88 }
89
90 pub fn fetch_async(&mut self, url: Url, id: usize, initiator: &Origin) {
97 self.fetch_request_async(NetworkRequest::get(url.to_string()), id, initiator);
98 }
99
100 pub fn fetch_request_async(&mut self, request: NetworkRequest, id: usize, initiator: &Origin) {
102 let Ok(url) = Url::parse(&request.url) else {
103 self.immediate_pool.push(BrowserNetworkMessage {
104 id,
105 response: Err(BrowserNetworkError::AnyhowError(anyhow!(
106 "Invalid request URL: {}",
107 request.url
108 ))),
109 });
110 return;
111 };
112 if !scheme_allowed(initiator, &url) {
113 log::warn!(
114 "Blocked {} from {} (internal scheme access denied)",
115 url,
116 initiator.ascii_serialization()
117 );
118 self.immediate_pool.push(BrowserNetworkMessage {
119 id,
120 response: Err(BrowserNetworkError::AnyhowError(anyhow!(
121 "Blocked request for {url}: the requesting page is not allowed to access this scheme"
122 ))),
123 });
124 return;
125 }
126 let Some(body) = load_immediate(&url) else {
127 if let Some(net) = &self.network {
128 net.fetch_request_async(request, id);
129 }
130 return;
131 };
132 let msg = BrowserNetworkMessage {
133 id,
134 response: if request.method == "GET" && request.body.is_empty() {
135 body.map(|body| make_response(&url, body))
136 .map_err(BrowserNetworkError::AnyhowError)
137 } else {
138 Err(BrowserNetworkError::AnyhowError(anyhow!(
139 "{} is not supported for {} URLs",
140 request.method,
141 url.scheme()
142 )))
143 },
144 };
145 self.immediate_pool.push(msg);
146 }
147
148 pub fn fetch_blocking(&self, url: Url) -> Result<BrowserResponse> {
149 if let Some(body) = load_immediate(&url) {
150 return body.map(|body| make_response(&url, body));
151 }
152 let Some(net) = &self.network else {
153 return Err(anyhow!("NetworkCore not available"));
154 };
155 net.fetch_blocking(url.as_str())
156 .map(|resp| BrowserResponse {
157 url: resp.url,
158 status: resp.status,
159 status_text: resp.reason_phrase,
160 body: resp.body,
161 headers: resp.headers,
162 })
163 .map_err(|e| anyhow!("NetworkError: {}", e))
164 }
165
166 pub fn try_receive(&mut self) -> Vec<BrowserNetworkMessage> {
168 let mut msgs: Vec<BrowserNetworkMessage> = self
169 .network
170 .as_ref()
171 .map(|net| {
172 net.try_receive()
173 .into_iter()
174 .map(|msg| BrowserNetworkMessage {
175 id: msg.msg_id,
176 response: msg
177 .response
178 .map(|resp| BrowserResponse {
179 url: resp.url,
180 status: resp.status,
181 status_text: resp.reason_phrase,
182 body: resp.body,
183 headers: resp.headers,
184 })
185 .map_err(BrowserNetworkError::NetworkError),
186 })
187 .collect()
188 })
189 .unwrap_or_default();
190 msgs.extend(std::mem::take(&mut self.immediate_pool));
191
192 msgs
193 }
194}
195
196fn load_immediate(url: &Url) -> Option<Result<Vec<u8>>> {
200 match url.scheme() {
201 "resource" => Some(ResourceURI::load(url.as_str())),
202 "data" => Some(DataURI::decode(url.as_str())),
203 "file" => Some(FileURI::load(url)),
204 _ => None,
205 }
206}
207
208fn make_response(url: &Url, body: Vec<u8>) -> BrowserResponse {
210 BrowserResponse {
211 url: url.to_string(),
212 status: hyper::StatusCode::OK.into(),
213 status_text: "OK".to_string(),
214 body,
215 headers: vec![],
216 }
217}
218
219pub struct BrowserResponse {
221 pub url: String,
222 pub status: StatusCode,
223 pub status_text: String,
224 pub body: Vec<u8>,
225 pub headers: Vec<(String, String)>,
226}
227
228pub struct BrowserNetworkMessage {
230 pub id: usize,
231 pub response: Result<BrowserResponse, BrowserNetworkError>,
232}
233
234#[derive(Debug)]
235pub enum BrowserNetworkError {
236 NetworkError(NetworkError),
237 AnyhowError(anyhow::Error),
238}
239
240impl fmt::Display for BrowserNetworkError {
241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242 match self {
243 Self::NetworkError(ne) => write!(f, "{ne}"),
244 Self::AnyhowError(ae) => write!(f, "{ae}"),
245 }
246 }
247}
248
249pub struct ResourceURI;
251
252impl ResourceURI {
253 pub fn load(url: &str) -> Result<Vec<u8>, anyhow::Error> {
254 use crate::platform::io;
255 if let Some(path) = url.strip_prefix("resource:///") {
256 io::load_resource(path)
257 } else {
258 Err(anyhow!("Unsupported scheme: {}", url))
259 }
260 }
261}
262
263pub struct DataURI;
269
270impl DataURI {
271 pub fn decode(url: &str) -> Result<Vec<u8>> {
272 let rest = url
273 .strip_prefix("data:")
274 .with_context(|| format!("Not a data: URL: {url}"))?;
275 let (metadata, payload) = rest
276 .split_once(',')
277 .context("data: URL is missing the ',' delimiter")?;
278
279 if metadata.to_ascii_lowercase().contains(";base64") {
280 let decoded = percent_decode(payload);
281 let cleaned: Vec<u8> = decoded
282 .into_iter()
283 .filter(|b| !b.is_ascii_whitespace())
284 .collect();
285 base64::engine::general_purpose::STANDARD
286 .decode(cleaned)
287 .context("failed to decode base64 data: URL")
288 } else {
289 Ok(percent_decode(payload))
290 }
291 }
292}
293
294pub struct FileURI;
300
301impl FileURI {
302 pub fn load(url: &Url) -> Result<Vec<u8>> {
303 let path = url
304 .to_file_path()
305 .map_err(|()| anyhow!("Unsupported file URL (non-local host): {url}"))?;
306 crate::platform::io::load_local_file(&path.to_string_lossy())
307 }
308}
309
310fn percent_decode(input: &str) -> Vec<u8> {
312 let bytes = input.as_bytes();
313 let mut out = Vec::with_capacity(bytes.len());
314 let mut i = 0;
315 while i < bytes.len() {
316 if bytes[i] == b'%'
317 && i + 2 < bytes.len()
318 && let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
319 {
320 out.push(hi * 16 + lo);
321 i += 3;
322 } else {
323 out.push(bytes[i]);
324 i += 1;
325 }
326 }
327 out
328}
329
330fn hex_value(b: u8) -> Option<u8> {
331 match b {
332 b'0'..=b'9' => Some(b - b'0'),
333 b'a'..=b'f' => Some(b - b'a' + 10),
334 b'A'..=b'F' => Some(b - b'A' + 10),
335 _ => None,
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use std::sync::atomic::{AtomicUsize, Ordering};
343
344 fn temp_file(contents: &[u8]) -> std::path::PathBuf {
347 static COUNTER: AtomicUsize = AtomicUsize::new(0);
348 let name = format!(
349 "orinium-file-uri-{}-{}",
350 std::process::id(),
351 COUNTER.fetch_add(1, Ordering::Relaxed)
352 );
353 let path = std::env::temp_dir().join(name);
354 std::fs::write(&path, contents).unwrap();
355 path
356 }
357
358 #[test]
359 fn data_uri_decodes_base64_payload() {
360 let encoded = base64::engine::general_purpose::STANDARD.encode(b"hello");
361 let url = format!("data:image/png;base64,{encoded}");
362 assert_eq!(DataURI::decode(&url).unwrap(), b"hello");
363 }
364
365 #[test]
366 fn data_uri_decodes_plain_payload() {
367 let url = "data:text/plain,hello%20world";
368 assert_eq!(DataURI::decode(url).unwrap(), b"hello world");
369 }
370
371 #[test]
372 fn data_uri_ignores_whitespace_in_base64_payload() {
373 let encoded = base64::engine::general_purpose::STANDARD.encode(b"line1line2");
374 let url = format!("data:text/plain;base64,{encoded}\n\r\t ");
375 assert_eq!(DataURI::decode(&url).unwrap(), b"line1line2");
376 }
377
378 #[test]
379 fn data_uri_rejects_missing_delimiter() {
380 assert!(DataURI::decode("data:text/plain").is_err());
381 }
382
383 #[test]
384 fn data_uri_rejects_invalid_base64() {
385 assert!(DataURI::decode("data:text/plain;base64,%%%").is_err());
386 }
387
388 #[test]
389 fn data_uri_flag_is_case_insensitive() {
390 let encoded = base64::engine::general_purpose::STANDARD.encode(b"ok");
391 let url = format!("data:text/plain;BASE64,{encoded}");
392 assert_eq!(DataURI::decode(&url).unwrap(), b"ok");
393 }
394
395 #[test]
396 fn url_parse_preserves_data_url() {
397 let url = Url::parse("data:image/png;base64,AAAA").unwrap();
398 assert_eq!(url.scheme(), "data");
399 assert_eq!(url.as_str(), "data:image/png;base64,AAAA");
400 }
401
402 #[test]
403 fn fetch_blocking_decodes_data_url_without_network() {
404 let loader = BrowserResourceLoader::new(None);
405 let encoded = base64::engine::general_purpose::STANDARD.encode(b"png-bytes");
406 let url = Url::parse(&format!("data:image/png;base64,{encoded}")).unwrap();
407
408 let resp = loader.fetch_blocking(url).unwrap();
409 assert!(resp.status.is_success());
410 assert_eq!(resp.body, b"png-bytes");
411 }
412
413 #[test]
414 fn fetch_async_pushes_data_url_into_immediate_pool() {
415 let mut loader = BrowserResourceLoader::new(None);
416 let url = Url::parse("data:text/plain,hi").unwrap();
417
418 loader.fetch_async(url, 7, &Origin::opaque());
419 let msgs = loader.try_receive();
420 assert_eq!(msgs.len(), 1);
421 assert_eq!(msgs[0].id, 7);
422 let resp = msgs[0].response.as_ref().unwrap();
423 assert_eq!(resp.body, b"hi");
424 }
425
426 #[test]
427 fn immediate_urls_reject_non_get_requests() {
428 let mut loader = BrowserResourceLoader::new(None);
429 loader.fetch_request_async(
430 NetworkRequest {
431 url: "data:text/plain,hi".to_string(),
432 method: "POST".to_string(),
433 headers: Vec::new(),
434 body: b"request body".to_vec(),
435 },
436 8,
437 &Origin::opaque(),
438 );
439
440 let messages = loader.try_receive();
441 assert_eq!(messages.len(), 1);
442 assert_eq!(messages[0].id, 8);
443 assert!(messages[0].response.is_err());
444 }
445
446 #[test]
447 fn network_origin_cannot_reach_resource_scheme() {
448 let mut loader = BrowserResourceLoader::new(None);
449 let web = Origin::from_url(&Url::parse("https://example.test/").unwrap());
450
451 loader.fetch_async(
452 Url::parse("resource:///devtools/index.html").unwrap(),
453 9,
454 &web,
455 );
456
457 let messages = loader.try_receive();
458 assert_eq!(messages.len(), 1);
459 assert_eq!(messages[0].id, 9);
460 assert!(messages[0].response.is_err());
461 }
462
463 #[test]
464 fn internal_origin_can_reach_resource_scheme() {
465 let mut loader = BrowserResourceLoader::new(None);
466 let internal = Origin::opaque();
467
468 loader.fetch_async(
469 Url::parse("resource:///devtools/index.html").unwrap(),
470 10,
471 &internal,
472 );
473
474 let messages = loader.try_receive();
475 assert_eq!(messages.len(), 1);
476 assert_eq!(messages[0].id, 10);
477 assert!(messages[0].response.as_ref().is_ok());
478 }
479
480 #[test]
481 fn any_origin_can_reach_data_scheme() {
482 let mut loader = BrowserResourceLoader::new(None);
483 let web = Origin::from_url(&Url::parse("https://example.test/").unwrap());
484
485 loader.fetch_async(Url::parse("data:text/plain,hi").unwrap(), 11, &web);
486
487 let messages = loader.try_receive();
488 assert_eq!(messages.len(), 1);
489 assert_eq!(messages[0].id, 11);
490 let resp = messages[0].response.as_ref().unwrap();
491 assert_eq!(resp.body, b"hi");
492 }
493
494 #[test]
495 fn fetch_blocking_rejects_data_url_without_network() {
496 let loader = BrowserResourceLoader::new(None);
497 let url = Url::parse("data:image/png;base64,@@@not-base64@@@").unwrap();
498 assert!(loader.fetch_blocking(url).is_err());
499 }
500
501 #[test]
502 fn file_uri_loads_local_file_bytes() {
503 let path = temp_file(b"file content");
504 let url = Url::from_file_path(&path).unwrap();
505
506 assert_eq!(FileURI::load(&url).unwrap(), b"file content");
507 let _ = std::fs::remove_file(&path);
508 }
509
510 #[test]
511 fn file_url_with_nonlocal_host_is_rejected() {
512 let url = Url::parse("file://evil.example/etc/passwd").unwrap();
513 assert!(FileURI::load(&url).is_err());
514 }
515
516 #[test]
517 fn fetch_async_resolves_file_url_into_immediate_pool() {
518 let path = temp_file(b"file bytes");
519 let url = Url::from_file_path(&path).unwrap();
520 let mut loader = BrowserResourceLoader::new(None);
521
522 loader.fetch_async(url, 12, &Origin::opaque());
523 let msgs = loader.try_receive();
524 assert_eq!(msgs.len(), 1);
525 assert_eq!(msgs[0].id, 12);
526 let resp = msgs[0].response.as_ref().unwrap();
527 assert_eq!(resp.body, b"file bytes");
528 let _ = std::fs::remove_file(&path);
529 }
530
531 #[test]
532 fn internal_origin_can_read_local_file() {
533 let path = temp_file(b"local data");
534 let url = Url::from_file_path(&path).unwrap();
535 let mut loader = BrowserResourceLoader::new(None);
536
537 loader.fetch_async(url, 13, &Origin::opaque());
538 let msgs = loader.try_receive();
539 assert_eq!(msgs.len(), 1);
540 let resp = msgs[0].response.as_ref().unwrap();
541 assert_eq!(resp.body, b"local data");
542 let _ = std::fs::remove_file(&path);
543 }
544
545 #[test]
546 fn network_origin_cannot_read_local_file() {
547 let path = temp_file(b"secret");
548 let url = Url::from_file_path(&path).unwrap();
549 let web = Origin::from_url(&Url::parse("https://example.test/").unwrap());
550 let mut loader = BrowserResourceLoader::new(None);
551
552 loader.fetch_async(url, 14, &web);
553 let msgs = loader.try_receive();
554 assert_eq!(msgs.len(), 1);
555 assert!(msgs[0].response.is_err());
556 let _ = std::fs::remove_file(&path);
557 }
558}