Skip to main content

orinium_browser/browser/core/
resource_loader.rs

1//! Browser resource loading process.
2//!
3//! Supports the `http(s)://` scheme (via the platform `NetworkCore`), and the
4//! network-free `resource:///`, `data:` and `file://` schemes.
5
6use 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
13/// Whether a document with `initiator` may load a resource addressed by `url`.
14///
15/// Web (network) origins may only reach external schemes and `data:`; every
16/// other custom scheme (`resource:`, `file:`, `about:`, unknown schemes)
17/// requires an opaque origin, i.e. a page that is itself internal.
18fn scheme_allowed(initiator: &Origin, url: &Url) -> bool {
19    match url.scheme() {
20        "http" | "https" | "data" => true,
21        _ => initiator.is_opaque(),
22    }
23}
24
25/// BrowserResourceLoader
26///
27/// High-level resource loading abstraction used by the browser core to obtain
28/// content for tabs and internal resources.
29///
30/// Responsibilities:
31/// - Resolve and fetch resources from `resource:///` scheme (bundled/local) and
32///   from standard HTTP/HTTPS URLs.
33/// - Decode `data:` URLs (base64 or percent-encoded payloads) without touching
34///   the network stack.
35/// - Provide a small synchronous/queuing abstraction over the platform network
36///   core so callers in the engine/browser can request resources without dealing
37///   with the network implementation details.
38///
39/// Processing flow (overview):
40/// 1. Caller requests a URL (`resource:///...`, `data:...` or `http(s)://...`).
41/// 2. Network-free schemes (`resource`, `data`) are resolved locally and pushed
42///    to `immediate_pool` as `BrowserNetworkMessage`s.
43/// 3. For HTTP/HTTPS, loader forwards the request to `NetworkCore` and manages
44///    request ids / pending responses. When the network reply is ready, the
45///    loader hands the response back to the browser/tab via the expected
46///    callback or message path.
47///
48/// Example usage:
49/// ```no_run
50/// use orinium_browser::browser::core::resource_loader::BrowserResourceLoader;
51/// use std::rc::Rc;
52/// use orinium_browser::platform::network::NetworkCore;
53///
54/// let network = Some(Rc::new(NetworkCore::new().unwrap()));
55/// let loader = BrowserResourceLoader::new(network);
56///
57/// // Typical call (pseudocode):
58/// // let body = loader.fetch(&url)?;
59/// // process body...
60/// ```
61///
62/// Notes for contributors:
63/// - Keep the loader focused on scheme resolution, simple caching/pooling,
64///   and delegation to `NetworkCore`. Avoid adding heavy parsing logic here.
65/// - Unit tests should validate `resource:///` and `data:` resolution and HTTP
66///   request delegation semantics (e.g. mapping of request IDs to responses).
67pub struct BrowserResourceLoader {
68    /// Optional platform network core used for HTTP/HTTPS requests.
69    pub network: Option<Rc<NetworkCore>>,
70
71    /// Immediate pool / internal queue for messages produced by the loader.
72    /// The concrete type `BrowserNetworkMessage` represents internal network
73    /// events; see the network module for details.
74    pub immediate_pool: Vec<BrowserNetworkMessage>,
75}
76
77impl BrowserResourceLoader {
78    /// Construct a new resource loader.
79    ///
80    /// `network` is optional to allow operating in environments where the
81    /// network stack is not available (tests, limited examples, or when only
82    /// `resource:///` is needed).
83    pub fn new(network: Option<Rc<NetworkCore>>) -> Self {
84        Self {
85            network,
86            immediate_pool: vec![],
87        }
88    }
89
90    /// Async fetch: resolve immediate schemes (`resource` / `data`) in place and
91    /// push the result to `immediate_pool`; delegate all other schemes to `NetworkCore`.
92    ///
93    /// `initiator` is the origin of the requesting document. Its scheme access
94    /// is enforced here: web (network) origins can never reach internal
95    /// `resource:`/custom scheme content.
96    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    /// Fetches a request while preserving method, headers, and body for HTTP(S).
101    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    /// Called from the UI thread: collect received network and immediate-scheme results.
167    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
196/// Loads the body of schemes that are resolved without the network.
197///
198/// Returns `None` for schemes that must be delegated to `NetworkCore`.
199fn 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
208/// Builds a 200 OK response from the body of an immediate scheme.
209fn 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
219/// 統一レスポンス
220pub 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
228/// ネットワーク結果を UI スレッドで受け取るためのラッパー
229pub 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
249/// resource:/// 専用
250pub 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
263/// `data:` URL decoder (RFC 2397)
264///
265/// Format: `data:[<mediatype>][;base64],<payload>`
266/// - With the `;base64` flag: base64-decode the payload (ignoring whitespace).
267/// - Otherwise: percent-decode the payload into bytes.
268pub 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
294/// `file://` 用ローダー。
295///
296/// URL をローカルファイルシステムのパスに変換して読み込む。`file://host/...`
297/// のように空でも `localhost` でもないホストを伴う URL は拒否し、ローカルの
298/// ファイル URL 以外は解決しない。
299pub 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
310/// Converts `%XX` sequences into their byte values. Invalid `%` sequences are kept as-is.
311fn 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    /// Creates a unique temporary file with `contents` and returns its path.
345    /// The caller is responsible for removing the file.
346    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}