blitz_traits/platform.rs
1//! Abstractions of the host platform APIs a guest expects to find: fetching,
2//! and per-origin storage.
3//!
4//! These sit beside [`crate::net`] rather than inside it, and the distinction
5//! is worth stating because it is not obvious from the names.
6//!
7//! [`NetProvider`](crate::net::NetProvider) loads *resources the document
8//! needs*: a stylesheet, an image, a script. Its completion callback is
9//! [`NetHandler::bytes`](crate::net::NetHandler), which takes a resolved URL
10//! and some bytes, and that is the whole answer. There is no status code and
11//! there are no response headers, because a caller loading an image has nothing
12//! to do with either: the bytes decode or they do not.
13//!
14//! A `fetch()` caller is the opposite. A 404 is not a failure to be logged and
15//! dropped, it is the answer, and so are the headers. So this is a second trait
16//! rather than a wider `NetProvider`: an embedder that only ever loads page
17//! resources keeps implementing the small one, and the two can share a client.
18//! `blitz-net` implements both over the same `reqwest` client, the same
19//! connection pool, the same cookie jar and the same disk cache.
20//!
21//! Nothing here performs IO or names an HTTP client. The implementations live
22//! with the embedder.
23
24pub use bytes::Bytes;
25/// Re-exported so a binding can name `HeaderName` and `HeaderValue` when
26/// building a request, without taking its own dependency on `http` and pinning
27/// it to whatever version this crate happens to resolve. Same reasoning as the
28/// `keyboard_types` re-export in the crate root.
29pub use http;
30pub use http::{HeaderMap, Method, StatusCode};
31use std::sync::atomic::{AtomicU64, Ordering};
32pub use url::Url;
33
34/// A type that performs `fetch`-style requests on behalf of a guest.
35///
36/// Asynchronous by construction: [`fetch`](FetchProvider::fetch) hands the
37/// request over and returns immediately, and the answer arrives through the
38/// handler on whatever thread the implementation chooses. Callers must not
39/// assume the handler runs later, or on another thread: an implementation
40/// serving a `data:` URL may answer before `fetch` returns.
41pub trait FetchProvider: Send + Sync + 'static {
42 fn fetch(&self, request: FetchRequest, handler: Box<dyn FetchHandler>);
43}
44
45/// Receives the result of one [`FetchProvider::fetch`].
46///
47/// Takes `self: Box<Self>` so that a handler can be consumed by completing,
48/// which makes "completed twice" unrepresentable rather than merely wrong.
49/// Same shape as [`NetHandler`](crate::net::NetHandler).
50pub trait FetchHandler: Send + Sync + 'static {
51 /// Called once unless the request is aborted before completion.
52 ///
53 /// **A response with a non-success status is `Ok`, not `Err`.** A 404 has a
54 /// status, headers and usually a body, all of which the caller asked for.
55 /// [`FetchError`] is for the cases where there is no response at all.
56 fn complete(self: Box<Self>, result: Result<FetchResponse, FetchError>);
57}
58
59/// A request, loosely <https://fetch.spec.whatwg.org/#requests>.
60///
61/// Narrower than [`Request`](crate::net::Request) on purpose: no form bodies,
62/// because a guest that wants a multipart body can encode one. Cancellation
63/// uses the same issuer-owned signal as document resource requests.
64#[non_exhaustive]
65#[derive(Debug, Clone)]
66pub struct FetchRequest {
67 pub url: Url,
68 pub method: Method,
69 pub headers: HeaderMap,
70 /// `None` is distinct from `Some(empty)`: the first sends no body at all,
71 /// the second sends a zero-length one with a `Content-Length: 0`.
72 pub body: Option<Bytes>,
73 /// Cancellation shared with the request's issuer.
74 pub signal: Option<crate::net::AbortSignal>,
75 pub initiator: crate::resource_policy::Initiator,
76 pub credentials: crate::resource_policy::Credentials,
77}
78
79impl FetchRequest {
80 /// A GET with no headers and no body.
81 pub fn get(url: Url) -> Self {
82 Self {
83 url,
84 method: Method::GET,
85 headers: HeaderMap::new(),
86 body: None,
87 signal: None,
88 initiator: crate::resource_policy::Initiator::default(),
89 credentials: crate::resource_policy::Credentials::default(),
90 }
91 }
92
93 pub fn initiator(mut self, initiator: crate::resource_policy::Initiator) -> Self {
94 self.initiator = initiator;
95 self
96 }
97
98 /// Attach the signal owned by this request's issuer.
99 pub fn signal(mut self, signal: crate::net::AbortSignal) -> Self {
100 self.signal = Some(signal);
101 self
102 }
103
104 /// The same request with a method.
105 pub fn method(mut self, method: Method) -> Self {
106 self.method = method;
107 self
108 }
109
110 /// The same request with a body.
111 pub fn body(mut self, body: Bytes) -> Self {
112 self.body = Some(body);
113 self
114 }
115}
116
117/// A response, loosely <https://fetch.spec.whatwg.org/#responses>.
118#[non_exhaustive]
119#[derive(Debug, Clone)]
120pub struct FetchResponse {
121 /// The URL the response actually came from, after any redirects.
122 ///
123 /// Not the requested URL. A guest resolving relative links out of a
124 /// response body needs the one it landed on.
125 pub url: Url,
126 pub status: StatusCode,
127 pub headers: HeaderMap,
128 pub body: Bytes,
129}
130
131impl FetchResponse {
132 /// A response with the given status and no headers or body.
133 ///
134 /// A constructor rather than a struct literal because the type is
135 /// `#[non_exhaustive]`, and it is `#[non_exhaustive]` because every
136 /// provider lives outside this crate: a field added here later would
137 /// otherwise break all of them at once. Build with this and the setters,
138 /// and a new field arrives with a default instead of a compile error.
139 pub fn new(url: Url, status: StatusCode) -> Self {
140 Self {
141 url,
142 status,
143 headers: HeaderMap::new(),
144 body: Bytes::new(),
145 }
146 }
147
148 pub fn headers(mut self, headers: HeaderMap) -> Self {
149 self.headers = headers;
150 self
151 }
152
153 pub fn body(mut self, body: Bytes) -> Self {
154 self.body = body;
155 self
156 }
157}
158
159/// Why there is no response.
160///
161/// Note what is *not* here: an HTTP status. A server that answered is a
162/// [`FetchResponse`] whatever it answered with. These are the cases where
163/// nothing answered.
164#[non_exhaustive]
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum FetchError {
167 /// The issuer aborted the owned fetch future.
168 Abort,
169 /// The request could not be completed: DNS, TLS, connection, timeout, a
170 /// malformed response, or a local read that failed.
171 ///
172 /// The string is for a human reading a log. TLS algorithm refusals use
173 /// `TlsPolicy` so callers can retain the reason without parsing this text.
174 Network(String),
175 /// The peer could not complete TLS under the permitted algorithm policy.
176 TlsPolicy(crate::tls_policy::TlsPolicyError),
177 /// The URL's scheme is not one this provider serves.
178 UnsupportedScheme(String),
179 /// The request was rejected before it was sent: an unparseable header, a
180 /// method the provider will not issue.
181 InvalidRequest(String),
182 /// Refused by policy rather than by the network. A provider that enforces
183 /// an allowlist, or refuses to let an opaque origin reach the network,
184 /// answers with this.
185 Blocked(String),
186 /// There is no provider installed. See [`DummyFetchProvider`].
187 NoProvider,
188}
189
190impl std::fmt::Display for FetchError {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 match self {
193 Self::Abort => write!(f, "request aborted"),
194 Self::Network(detail) => write!(f, "network error: {detail}"),
195 Self::TlsPolicy(reason) => write!(f, "TLS policy: {reason}"),
196 Self::UnsupportedScheme(scheme) => write!(f, "unsupported URL scheme: {scheme}"),
197 Self::InvalidRequest(detail) => write!(f, "invalid request: {detail}"),
198 Self::Blocked(reason) => write!(f, "blocked: {reason}"),
199 Self::NoProvider => write!(f, "no fetch provider is installed"),
200 }
201 }
202}
203
204impl std::error::Error for FetchError {}
205
206/// A [`FetchProvider`] that answers every request with [`FetchError::NoProvider`].
207///
208/// **It answers rather than doing nothing, which is where it differs from
209/// [`DummyNetProvider`](crate::net::DummyNetProvider), and the difference is
210/// deliberate.** A dropped resource load leaves an image unpainted, which is
211/// visible and survivable. A dropped `fetch` leaves the guest waiting on an
212/// answer that will never come, and a guest awaiting a promise that never
213/// settles is indistinguishable from a hang. So the no-op provider here fails
214/// fast instead of going quiet.
215#[derive(Default)]
216pub struct DummyFetchProvider;
217
218impl FetchProvider for DummyFetchProvider {
219 fn fetch(&self, _request: FetchRequest, handler: Box<dyn FetchHandler>) {
220 handler.complete(Err(FetchError::NoProvider));
221 }
222}
223
224/// The security principal a piece of stored state belongs to.
225///
226/// **Storage is keyed by this and never by a URL.** Two pages on one origin
227/// share storage; two pages on different origins must not see each other's,
228/// and that is a security property rather than a nicety.
229///
230/// Derived through [`Url::origin`], which is Servo's implementation of the
231/// WHATWG origin concept, so the awkward cases are already decided: `blob:`
232/// unwraps to its inner origin, and `file:` and `data:` are *opaque*.
233///
234/// # Opaque origins
235///
236/// [`Url::origin`] mints a **fresh** opaque origin on every call for a `file:`
237/// URL, so a document must derive its key once, at construction, and hold it.
238/// Deriving twice would give one document two identities and lose its own
239/// writes.
240///
241/// That is exactly the intended behaviour and not a wrinkle to route around.
242/// Collapsing every `file:` page into one shared bucket would mean any local
243/// HTML file could read any other's saved state, which is the same class of
244/// mistake as `blitz-net`'s `file:` handler reading any path it is handed.
245/// One such hole is enough.
246///
247/// [`is_persistable`](OriginKey::is_persistable) is the flag a disk-backed
248/// provider must check: an opaque origin is unique to one instance of one
249/// document, so writing it to disk stores a row that can never be read again.
250#[derive(Debug, Clone, PartialEq, Eq, Hash)]
251pub struct OriginKey {
252 key: String,
253 persistable: bool,
254}
255
256/// Distinguishes one opaque origin from the next within a process.
257static NEXT_OPAQUE: AtomicU64 = AtomicU64::new(0);
258
259impl OriginKey {
260 /// The origin of a document loaded from `url`.
261 ///
262 /// Call once per document and keep the result. See the type's docs for why
263 /// calling twice is a bug for `file:` and `data:` URLs.
264 pub fn for_document(url: &Url) -> Self {
265 let origin = url.origin();
266 if origin.is_tuple() {
267 Self {
268 key: origin.ascii_serialization(),
269 persistable: true,
270 }
271 } else {
272 Self::opaque()
273 }
274 }
275
276 /// A fresh opaque origin, shared with nothing.
277 ///
278 /// The serialisation of every opaque origin is the string `null`, so it
279 /// cannot be used to tell two of them apart. This appends a
280 /// process-unique counter so that a provider keying an in-memory map on
281 /// [`as_str`](OriginKey::as_str) isolates them from each other, which
282 /// `null` alone would not.
283 pub fn opaque() -> Self {
284 let nth = NEXT_OPAQUE.fetch_add(1, Ordering::Relaxed);
285 Self {
286 key: format!("null:{nth}"),
287 persistable: false,
288 }
289 }
290
291 /// A stable string for this origin, suitable as a map or table key.
292 ///
293 /// Unique per origin, including across opaque ones. Not a URL, and not to
294 /// be parsed back into one.
295 pub fn as_str(&self) -> &str {
296 &self.key
297 }
298
299 /// Whether state under this origin may be written to disk.
300 ///
301 /// False for opaque origins. A provider that persists must check this and
302 /// keep the rest in memory; see the type's docs.
303 pub fn is_persistable(&self) -> bool {
304 self.persistable
305 }
306}
307
308/// Per-origin key/value storage, the model behind `localStorage`.
309///
310/// Synchronous, because the API it backs is. Values are strings, because the
311/// API it backs stores strings: a caller with structure serialises it, exactly
312/// as it would against a browser, and no implementation here looks inside a
313/// value.
314///
315/// **Every method takes an [`OriginKey`], and an implementation must scope by
316/// it.** This is the whole security contract of the trait and it is not
317/// optional. An implementation that ignores the argument compiles, works in a
318/// single-origin test, and leaks every site's data to every other.
319pub trait StorageProvider: Send + Sync + 'static {
320 fn get(&self, origin: &OriginKey, key: &str) -> Option<String>;
321 fn set(&self, origin: &OriginKey, key: &str, value: &str) -> Result<(), StorageError>;
322 fn remove(&self, origin: &OriginKey, key: &str);
323 /// Removes everything under `origin`, and nothing under any other.
324 fn clear(&self, origin: &OriginKey);
325}
326
327/// Why a write did not happen.
328///
329/// Reads, removes and clears do not fail: an absent key is `None` and removing
330/// what is not there is a no-op, which is what the storage API specifies.
331#[non_exhaustive]
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub enum StorageError {
334 /// The origin is at its limit. `localStorage` surfaces this as a
335 /// `QuotaExceededError`, which is the one storage failure guests handle.
336 QuotaExceeded,
337 /// The backing store refused the write.
338 Backend(String),
339}
340
341impl std::fmt::Display for StorageError {
342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343 match self {
344 Self::QuotaExceeded => write!(f, "storage quota exceeded for this origin"),
345 Self::Backend(detail) => write!(f, "storage backend error: {detail}"),
346 }
347 }
348}
349
350impl std::error::Error for StorageError {}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 fn url(text: &str) -> Url {
357 Url::parse(text).unwrap()
358 }
359
360 #[test]
361 fn same_origin_urls_share_a_key() {
362 let a = OriginKey::for_document(&url("https://example.com/one?x=1#frag"));
363 let b = OriginKey::for_document(&url("https://example.com/two"));
364 assert_eq!(a, b);
365 assert!(a.is_persistable());
366 }
367
368 #[test]
369 fn scheme_port_and_host_all_separate_origins() {
370 let https = OriginKey::for_document(&url("https://example.com/"));
371 let http = OriginKey::for_document(&url("http://example.com/"));
372 let port = OriginKey::for_document(&url("https://example.com:8443/"));
373 let host = OriginKey::for_document(&url("https://other.example.com/"));
374 assert_ne!(https, http);
375 assert_ne!(https, port);
376 assert_ne!(https, host);
377 }
378
379 /// The property that stops one local page reading another's state. Two
380 /// *identical* file URLs must still be different origins.
381 #[test]
382 fn every_file_url_gets_its_own_opaque_origin() {
383 let one = OriginKey::for_document(&url("file:///home/user/page.html"));
384 let same_again = OriginKey::for_document(&url("file:///home/user/page.html"));
385 let other = OriginKey::for_document(&url("file:///home/user/other.html"));
386
387 assert_ne!(one, same_again);
388 assert_ne!(one, other);
389 assert!(!one.is_persistable());
390 assert!(!same_again.is_persistable());
391 }
392
393 #[test]
394 fn data_urls_are_opaque_too() {
395 let key = OriginKey::for_document(&url("data:text/html,<p>hi"));
396 assert!(!key.is_persistable());
397 }
398
399 /// `ascii_serialization` answers `null` for every opaque origin, so a
400 /// provider keying on it alone would merge them all into one bucket. This
401 /// is the assertion that the counter is doing its job.
402 #[test]
403 fn opaque_keys_are_distinguishable_from_each_other() {
404 let one = OriginKey::opaque();
405 let two = OriginKey::opaque();
406 assert_ne!(one.as_str(), two.as_str());
407 assert_ne!(one.as_str(), "null");
408 }
409
410 /// A `blob:` URL carries its origin inside it, and that origin is the one
411 /// that owns any state reached through it.
412 #[test]
413 fn a_blob_url_takes_the_origin_it_was_minted_from() {
414 let blob = OriginKey::for_document(&url("blob:https://example.com/uuid-goes-here"));
415 let page = OriginKey::for_document(&url("https://example.com/index.html"));
416 assert_eq!(blob, page);
417 }
418
419 #[test]
420 fn the_dummy_provider_answers_rather_than_going_quiet() {
421 use std::sync::{Arc, Mutex};
422
423 struct Record(Arc<Mutex<Option<Result<FetchResponse, FetchError>>>>);
424 impl FetchHandler for Record {
425 fn complete(self: Box<Self>, result: Result<FetchResponse, FetchError>) {
426 *self.0.lock().unwrap() = Some(result);
427 }
428 }
429
430 let seen = Arc::new(Mutex::new(None));
431 DummyFetchProvider.fetch(
432 FetchRequest::get(url("https://example.com/")),
433 Box::new(Record(seen.clone())),
434 );
435
436 let answer = seen.lock().unwrap().take();
437 assert_eq!(
438 answer.expect("the dummy must answer").unwrap_err(),
439 FetchError::NoProvider
440 );
441 }
442}