Skip to main content

mkit_server/http_objects/
mod.rs

1//! Runtime-agnostic HTTP object serving (SPEC-HTTP-OBJECTS; WP-4.12, R-169).
2//!
3//! [`crate::pipeline::Pipeline::serve_http_object`] takes the raw escaped
4//! path, query and headers of a GET, HEAD or OPTIONS request and returns a
5//! complete [`HttpObjectResponse`]: no `http` crate, no runtime. A binding
6//! (WP-4.16) mounts it, adds CORS and streams the body.
7//!
8//! The `http-objects` feature is off by default. Native embedders and the
9//! Paid Workers launch can opt in through their adapter features and
10//! mount configuration. The handler requires indexed mode and
11//! `PipelineConfig::http_objects`.
12//!
13//! The pieces: [`route`] is the §2 parser, [`range`] the conditional and
14//! byte-range rules, `resolve` published resolution and the byte source
15//! (extracted objects held by this repository, else its own pack entry),
16//! [`reach`] the reachability proof, `body` the length-enforcing body, and
17//! [`seams`] the hooks for admission (4.13), proofs (4.14b), tokens (4.15),
18//! mounting (4.16) and takedown (5.9a).
19
20mod body;
21pub(crate) mod content_headers;
22pub mod mount;
23mod paid;
24pub(crate) mod proof;
25pub mod range;
26pub mod reach;
27pub(crate) mod resolve;
28pub mod route;
29pub mod seams;
30
31pub use body::{EndHook, HttpBody, exact as exact_body, with_hook as body_with_hook};
32pub use paid::HttpReadRuntime;
33pub(crate) use paid::ReadFinalizer;
34pub use reach::{Reachability, TtlReachability};
35pub use route::{BadUrl, ParsedUrl, Query, RepoPrefix, Target, is_http_object_path, parse};
36pub use seams::{
37    AdmitDecision, AdmitRequest, Admitted, HttpAdmission, HttpSeams, NoAdmission, NoTakedown,
38    NoTokens, PreparedProof, ProofServer, ProofSource, TakedownGate, TakedownVerdict, TokenGate,
39    UnsupportedProofs,
40};
41
42/// Header lookup safe to retain across a native response future.
43#[cfg(not(target_arch = "wasm32"))]
44pub type HttpHeaderValues<'a> = dyn Fn(&str) -> Vec<String> + Sync + 'a;
45/// Workers run request futures on one thread.
46#[cfg(target_arch = "wasm32")]
47pub type HttpHeaderValues<'a> = dyn Fn(&str) -> Vec<String> + 'a;
48use crate::{Code, ServerError};
49
50/// Counter: ref enumeration or a reachability walk hit its row, page or
51/// decode budget
52/// and answered the uniform 404.
53pub const METRIC_HTTP_REACH_CAPPED: &str = "mkit_server_http_reach_capped_total";
54/// Counter: an object without an extracted copy exceeded
55/// `max_inline_object_bytes` and answered 503.
56pub const METRIC_HTTP_INLINE_CAPPED: &str = "mkit_server_http_inline_capped_total";
57
58/// Default [`HttpObjectsConfig::max_inline_object_bytes`]: 64 MiB.
59pub const DEFAULT_MAX_INLINE_OBJECT_BYTES: u64 = 64 << 20;
60
61/// Limits of opt-in HTTP object serving.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63#[non_exhaustive]
64pub struct HttpObjectsConfig {
65    /// Most objects one reachability walk decides, and most ref rows scanned
66    /// (including excluded refs and shard prefetch). Enumeration also has a
67    /// page budget of this limit divided by the configured page size, rounded
68    /// up. Exhaustion is 404 and [`METRIC_HTTP_REACH_CAPPED`].
69    pub max_walk_objects: usize,
70    /// Run the Admission hook for GET and HEAD at step 11. Default off.
71    pub admit_reads: bool,
72    /// Opt-in public ref redirects after all earlier checks; disabled with admission.
73    pub redirect_public_refs: bool,
74    /// Maximum paid transmission time from reservation creation.
75    pub read_deadline: core::time::Duration,
76    /// Time reserved for completion persistence before abandonment (default 60 s).
77    pub read_reconcile_grace: core::time::Duration,
78    /// How long a reachability proof is trusted, in milliseconds: a rewind
79    /// or ref deletion is visible within it (§4 `reachability_lag`).
80    pub reachability_lag_ms: u64,
81    /// Most rows of the positive reachability cache.
82    pub reach_cache_entries: usize,
83    /// Largest object served from its pack entry rather than an extracted
84    /// copy, at least `extract_min_bytes + 10`. Larger is 503.
85    pub max_inline_object_bytes: u64,
86    /// Decode bytes one request may spend on resolution and the reachability
87    /// walk; at least `max_inline_object_bytes`. The inline byte source has
88    /// its own `max_inline_object_bytes` allowance on top.
89    pub http_decode_budget: u64,
90    /// Maximum requested proof range content (checked before encoded size).
91    pub max_proof_content_bytes: u64,
92    /// Maximum encoded proof, at most SPEC-DISCLOSURE's 64 MiB cap.
93    pub max_proof_bundle_bytes: u64,
94}
95
96impl Default for HttpObjectsConfig {
97    fn default() -> Self {
98        Self {
99            max_walk_objects: 50_000,
100            admit_reads: false,
101            redirect_public_refs: false,
102            read_deadline: core::time::Duration::from_mins(5),
103            read_reconcile_grace: core::time::Duration::from_mins(1),
104            reachability_lag_ms: 60_000,
105            reach_cache_entries: 65_536,
106            max_inline_object_bytes: DEFAULT_MAX_INLINE_OBJECT_BYTES,
107            http_decode_budget: 256 << 20,
108            max_proof_content_bytes: 8 << 20,
109            max_proof_bundle_bytes: 64 << 20,
110        }
111    }
112}
113
114impl HttpObjectsConfig {
115    /// Check the limits against the indexed configuration they run under.
116    ///
117    /// # Errors
118    /// `invalid_argument` for a zero limit, an inline cap below
119    /// `extract_min_bytes + 10`, or a decode budget below the inline cap.
120    pub fn validate(&self, extract_min_bytes: u64) -> Result<(), ServerError> {
121        if self.max_proof_content_bytes == 0
122            || self.max_proof_bundle_bytes == 0
123            || self.max_proof_bundle_bytes > 64 << 20
124            || self.read_deadline.is_zero()
125            || self.read_reconcile_grace.is_zero()
126            || self.max_walk_objects == 0
127            || self.reachability_lag_ms == 0
128            || self.reach_cache_entries == 0
129            || self.max_inline_object_bytes < extract_min_bytes.saturating_add(10)
130            || self.http_decode_budget < self.max_inline_object_bytes
131        {
132            return Err(ServerError::invalid_argument("invalid HTTP object limits"));
133        }
134        Ok(())
135    }
136}
137
138/// An escaped query whose contents are available only to the URL parser.
139/// Debug and Display always redact it, including when formatted on its own.
140#[derive(Clone, Copy)]
141pub struct RedactedQuery<'a>(&'a str);
142
143impl<'a> RedactedQuery<'a> {
144    /// Wrap the query exactly as received, without the leading `?`.
145    #[must_use]
146    pub fn new(query: &'a str) -> Self {
147        Self(query)
148    }
149}
150
151impl core::fmt::Debug for RedactedQuery<'_> {
152    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
153        f.write_str("[redacted]")
154    }
155}
156
157impl core::fmt::Display for RedactedQuery<'_> {
158    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
159        f.write_str("[redacted]")
160    }
161}
162
163/// One request as a binding presents it. `raw_path` and `raw_query` are
164/// exactly as received (still escaped, no leading `?`): framework decoding
165/// must not reinterpret delimiters (§2). Neither is ever logged.
166pub struct HttpObjectRequest<'a> {
167    /// `GET`, `HEAD`, `OPTIONS` or anything else (405).
168    pub method: &'a str,
169    /// The escaped path.
170    pub raw_path: &'a str,
171    /// The escaped query, without the `?`. A trailing `?` with nothing after
172    /// it is `Some(RedactedQuery::new(""))`, which is a 400 (§2); a mount
173    /// that drops it must not present it as `None`.
174    pub raw_query: Option<RedactedQuery<'a>>,
175    /// Multi-value header lookup by lowercase name.
176    pub headers: &'a HttpHeaderValues<'a>,
177    /// Header names as received by the adapter, including repeated fields.
178    /// Credential forwarding preserves this spelling; values stay in `headers`.
179    pub header_names: &'a [&'a str],
180}
181
182impl core::fmt::Debug for HttpObjectRequest<'_> {
183    /// Never shows the path, query or headers.
184    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
185        f.debug_struct("HttpObjectRequest")
186            .field("method", &self.method)
187            .finish_non_exhaustive()
188    }
189}
190
191/// A complete response. Header names are static; values never carry request
192/// text verbatim. Filenames are derived and fully percent-encoded outside
193/// RFC 5987 attr-char, with a sanitized ASCII fallback, preventing injection.
194#[derive(Debug)]
195pub struct HttpObjectResponse {
196    /// The status code.
197    pub status: u16,
198    /// Response headers.
199    pub headers: Vec<(&'static str, String)>,
200    /// The body; empty for HEAD on every status.
201    pub body: HttpBody,
202}
203
204/// Sent on every response, errors included (§5.3).
205const SECURITY_HEADERS: [(&str, &str); 3] = [
206    ("X-Content-Type-Options", "nosniff"),
207    ("Content-Security-Policy", "sandbox; default-src 'none'"),
208    ("Referrer-Policy", "no-referrer"),
209];
210
211/// The one body of every 404, so no miss can be told from another.
212const NOT_FOUND_BODY: &[u8] = b"not found";
213
214impl HttpObjectResponse {
215    /// A response with the security headers and no body.
216    #[must_use]
217    pub fn new(status: u16) -> Self {
218        Self {
219            status,
220            headers: SECURITY_HEADERS
221                .iter()
222                .map(|(name, value)| (*name, (*value).to_owned()))
223                .collect(),
224            body: HttpBody::Empty,
225        }
226    }
227
228    /// `self` with one more header.
229    #[must_use]
230    pub fn with_header(mut self, name: &'static str, value: impl Into<String>) -> Self {
231        self.headers.push((name, value.into()));
232        self
233    }
234
235    /// An error response: `no-store` (§3), no body.
236    #[must_use]
237    pub fn error(status: u16) -> Self {
238        Self::new(status).with_header("Cache-Control", "no-store")
239    }
240
241    /// The uniform 404, byte-identical for every miss (§3).
242    #[must_use]
243    pub fn not_found() -> Self {
244        let mut response = Self::error(404)
245            .with_header("Content-Type", "text/plain; charset=utf-8")
246            .with_header("Content-Length", NOT_FOUND_BODY.len().to_string());
247        response.body = HttpBody::Bytes(bytes::Bytes::from_static(NOT_FOUND_BODY));
248        response
249    }
250
251    /// The first header named `name`, ignoring ASCII case.
252    #[must_use]
253    pub fn header(&self, name: &str) -> Option<&str> {
254        self.headers
255            .iter()
256            .find(|(n, _)| n.eq_ignore_ascii_case(name))
257            .map(|(_, v)| v.as_str())
258    }
259}
260
261/// The Cache-Control of a successful public response (§5.3); `private`
262/// replaces `public` when Admission ran.
263pub(crate) fn cache_control(ref_path: bool, private: bool) -> &'static str {
264    match (ref_path, private) {
265        (false, false) => "public, max-age=31536000, immutable",
266        (false, true) => "private, max-age=31536000, immutable",
267        (true, false) => "public, no-cache",
268        (true, true) => "private, no-cache",
269    }
270}
271
272/// How a failed request maps to a response.
273#[derive(Debug)]
274pub(crate) enum Fail {
275    /// The uniform 404.
276    NotFound,
277    /// 403.
278    Forbidden,
279    /// Unsupported selector, bounds, overflow or proof cap: 416.
280    ProofRange,
281    /// 503: a store, hook or invariant failure. Fails closed.
282    Unavailable,
283}
284
285impl Fail {
286    /// Map an authorization or hook error (§3 step 6): `not_found` and a
287    /// private repository are 404, a denial (including `unauthenticated` on a
288    /// public read, which §3 only lets be 403) is 403, everything else is 503.
289    pub(crate) fn from_server_error(error: &ServerError) -> Self {
290        match error.code() {
291            Code::NotFound => Self::NotFound,
292            Code::PermissionDenied | Code::Unauthenticated => Self::Forbidden,
293            _ => Self::Unavailable,
294        }
295    }
296
297    /// The code to record in request metrics.
298    pub(crate) fn code(&self) -> Code {
299        match self {
300            Self::NotFound => Code::NotFound,
301            Self::ProofRange => Code::OutOfRange,
302            Self::Forbidden => Code::PermissionDenied,
303            Self::Unavailable => Code::Unavailable,
304        }
305    }
306
307    pub(crate) fn into_response(self) -> HttpObjectResponse {
308        match self {
309            Self::NotFound => HttpObjectResponse::not_found(),
310            Self::ProofRange => HttpObjectResponse::error(416),
311            Self::Forbidden => HttpObjectResponse::error(403),
312            Self::Unavailable => HttpObjectResponse::error(503),
313        }
314    }
315}
316
317impl From<resolve::Miss> for Fail {
318    fn from(miss: resolve::Miss) -> Self {
319        match miss {
320            resolve::Miss::NotFound => Self::NotFound,
321            resolve::Miss::Capped | resolve::Miss::Unavailable => Self::Unavailable,
322        }
323    }
324}