pharos_runtime_core/http.rs
1/// Maximum accepted size of the full HTTP request head.
2///
3/// # Examples
4///
5/// ```rust,ignore
6/// let _ = pharos_runtime_core::http::PHAROS_HTTP_MAX_HEADER_BYTES;
7/// ```
8///
9/// # Related
10///
11/// - [`crate::http`] for the surrounding module surface.
12pub const PHAROS_HTTP_MAX_HEADER_BYTES: usize = 64 * 1024;
13/// Maximum accepted size of the request line.
14///
15/// # Examples
16///
17/// ```rust,ignore
18/// let _ = pharos_runtime_core::http::PHAROS_HTTP_MAX_REQUEST_LINE_BYTES;
19/// ```
20///
21/// # Related
22///
23/// - [`crate::http`] for the surrounding module surface.
24pub const PHAROS_HTTP_MAX_REQUEST_LINE_BYTES: usize = 8 * 1024;
25/// Maximum accepted size of any single HTTP header line.
26///
27/// # Examples
28///
29/// ```rust,ignore
30/// let _ = pharos_runtime_core::http::PHAROS_HTTP_MAX_HEADER_LINE_BYTES;
31/// ```
32///
33/// # Related
34///
35/// - [`crate::http`] for the surrounding module surface.
36pub const PHAROS_HTTP_MAX_HEADER_LINE_BYTES: usize = 16 * 1024;
37/// Maximum accepted size of a non-multipart request body.
38///
39/// # Examples
40///
41/// ```rust,ignore
42/// let _ = pharos_runtime_core::http::PHAROS_HTTP_MAX_BODY_BYTES;
43/// ```
44///
45/// # Related
46///
47/// - [`crate::http`] for the surrounding module surface.
48pub const PHAROS_HTTP_MAX_BODY_BYTES: usize = 1024 * 1024;
49/// Maximum accepted size of a multipart request body.
50///
51/// # Examples
52///
53/// ```rust,ignore
54/// let _ = pharos_runtime_core::http::PHAROS_HTTP_MAX_MULTIPART_BODY_BYTES;
55/// ```
56///
57/// # Related
58///
59/// - [`crate::http`] for the surrounding module surface.
60pub const PHAROS_HTTP_MAX_MULTIPART_BODY_BYTES: usize = 16 * 1024 * 1024;
61
62/// Returns the current wall-clock time in microseconds since the Unix epoch.
63///
64/// # Returns
65///
66/// Returns the computed value produced by [`crate::http::win_gettimeofday`].
67///
68/// # Examples
69///
70/// ```rust,ignore
71/// let _result = pharos_runtime_core::http::win_gettimeofday();
72/// ```
73///
74/// # Related
75///
76/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
77pub fn win_gettimeofday() -> i64 {
78 SystemTime::now()
79 .duration_since(UNIX_EPOCH)
80 .map(|value| value.as_micros() as i64)
81 .unwrap_or(0)
82}
83
84use std::fs::File;
85use std::io::Write;
86use std::net::IpAddr;
87use std::path::Path;
88use std::time::{SystemTime, UNIX_EPOCH};
89
90/// Returns whether sensitive header name.
91fn is_sensitive_header_name(name: &str) -> bool {
92 matches!(
93 &name.to_ascii_lowercase()[..],
94 "authorization" | "cookie" | "set-cookie" | "x-api-key" | "x-pharos-admin-secret"
95 )
96}
97
98#[derive(Debug, Clone, Default, Eq, PartialEq)]
99/// Owned HTTP request data used by the runtime after parsing and body capture.
100///
101/// # Fields
102///
103/// - `method`: Stored field on `HttpRequest`.
104/// - `target`: Stored field on `HttpRequest`.
105/// - `cookie`: Stored field on `HttpRequest`.
106/// - `content_type`: Stored field on `HttpRequest`.
107/// - `headers`: Stored field on `HttpRequest`.
108/// - `body`: Stored field on `HttpRequest`.
109/// - `body_file_path`: Stored field on `HttpRequest`.
110/// - `body_len`: Stored field on `HttpRequest`.
111/// - `is_multipart`: Stored field on `HttpRequest`.
112///
113/// # Examples
114///
115/// ```rust,ignore
116/// let _ = std::mem::size_of::<pharos_runtime_core::http::HttpRequest>();
117/// ```
118///
119/// # Related
120///
121/// - [`crate::http`] for constructors, helpers, and neighboring runtime types.
122pub struct HttpRequest {
123 pub method: String,
124 pub target: String,
125 pub cookie: Option<String>,
126 pub content_type: Option<String>,
127 pub headers: String,
128 pub body: Vec<u8>,
129 pub body_file_path: Option<String>,
130 pub body_len: usize,
131 pub is_multipart: bool,
132}
133
134#[derive(Debug, Clone, Eq, PartialEq)]
135/// Minimal parsed request head returned by [`parse_http_request`].
136///
137/// # Fields
138///
139/// - `method`: Stored field on `ParsedHttpRequest`.
140/// - `target`: Stored field on `ParsedHttpRequest`.
141/// - `headers`: Stored field on `ParsedHttpRequest`.
142///
143/// # Examples
144///
145/// ```rust,ignore
146/// let _ = std::mem::size_of::<pharos_runtime_core::http::ParsedHttpRequest>();
147/// ```
148///
149/// # Related
150///
151/// - [`crate::http`] for constructors, helpers, and neighboring runtime types.
152pub struct ParsedHttpRequest {
153 pub method: String,
154 pub target: String,
155 pub headers: Vec<(String, String)>,
156}
157/// Releases owned request buffers and removes any temporary body file.
158///
159/// # Parameters
160///
161/// - `request`: Input consumed by [`crate::http::free_http_request`].
162///
163/// # Returns
164///
165/// Returns after [`crate::http::free_http_request`] applies its in-place effect.
166///
167/// # Examples
168///
169/// ```rust,ignore
170/// let _result = pharos_runtime_core::http::free_http_request(todo!());
171/// ```
172///
173/// # Related
174///
175/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
176pub fn free_http_request(request: &mut HttpRequest) {
177 if let Some(path) = request.body_file_path.take() {
178 let _ = std::fs::remove_file(path);
179 }
180 *request = HttpRequest::default();
181}
182
183/// Parses the content length value.
184fn content_length_value(value: &str) -> Option<i64> {
185 let trimmed = value.trim();
186 if trimmed.is_empty() || !trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
187 return None;
188 }
189 trimmed.parse::<u64>().ok()?.try_into().ok()
190}
191/// Returns whether a `Transfer-Encoding` header includes `chunked`.
192///
193/// # Parameters
194///
195/// - `value`: Input consumed by [`crate::http::contains_chunked_transfer`].
196///
197/// # Returns
198///
199/// Returns `true` when the condition checked by [`crate::http::contains_chunked_transfer`] holds and `false` otherwise.
200///
201/// # Performance
202///
203/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::contains_chunked_transfer`].
204///
205/// # Examples
206///
207/// ```rust,ignore
208/// let _result = pharos_runtime_core::http::contains_chunked_transfer("value");
209/// ```
210///
211/// # Related
212///
213/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
214pub fn contains_chunked_transfer(value: &str) -> bool {
215 value
216 .split(',')
217 .any(|token| token.trim().eq_ignore_ascii_case("chunked"))
218}
219
220/// Parses a decimal `Content-Length` header using the runtime's non-negative bounds.
221///
222/// # Parameters
223///
224/// - `value`: Input consumed by [`crate::http::parse_content_length_header`].
225///
226/// # Returns
227///
228/// Returns the successful value produced by [`crate::http::parse_content_length_header`] or an error when the operation fails.
229///
230/// # Errors
231///
232/// Returns an error value when [`crate::http::parse_content_length_header`] cannot complete successfully.
233///
234/// # Performance
235///
236/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::parse_content_length_header`].
237///
238/// # Examples
239///
240/// ```rust,ignore
241/// let _result = pharos_runtime_core::http::parse_content_length_header("value");
242/// ```
243///
244/// # Related
245///
246/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
247pub fn parse_content_length_header(value: &str) -> Result<i64, i32> {
248 let trimmed = value.trim();
249 if trimmed.is_empty() || !trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
250 return Err(400);
251 }
252 trimmed
253 .parse::<u64>()
254 .ok()
255 .and_then(|value| i64::try_from(value).ok())
256 .ok_or(400)
257}
258
259/// Writes one bounded body chunk, retrying short writes without invoking external code.
260///
261/// # Parameters
262///
263/// - `file`: Input consumed by [`crate::http::write_temp_body_chunk`].
264/// - `data`: Input consumed by [`crate::http::write_temp_body_chunk`].
265///
266/// # Returns
267///
268/// Returns the computed value produced by [`crate::http::write_temp_body_chunk`].
269///
270/// # Examples
271///
272/// ```rust,ignore
273/// let _result = pharos_runtime_core::http::write_temp_body_chunk(todo!(), b"");
274/// ```
275///
276/// # Related
277///
278/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
279pub fn write_temp_body_chunk(file: &mut File, data: &[u8]) -> i32 {
280 let mut written = 0usize;
281 while written < data.len() {
282 match file.write(&data[written..]) {
283 Ok(0) | Err(_) => return 69,
284 Ok(count) => written += count,
285 }
286 }
287 0
288}
289
290/// Returns the MIME type permitted for a static artifact path.
291///
292/// # Parameters
293///
294/// - `path`: Input consumed by [`crate::http::mime_type_for_path`].
295///
296/// # Returns
297///
298/// Returns the computed value produced by [`crate::http::mime_type_for_path`].
299///
300/// # Examples
301///
302/// ```rust,ignore
303/// let _result = pharos_runtime_core::http::mime_type_for_path(std::path::Path::new("fixture.json"));
304/// ```
305///
306/// # Related
307///
308/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
309pub fn mime_type_for_path(path: &Path) -> &'static str {
310 match path
311 .extension()
312 .and_then(|value| value.to_str())
313 .map(str::to_ascii_lowercase)
314 .as_deref()
315 {
316 Some("html") | Some("htm") => "text/html; charset=utf-8",
317 Some("json") => "application/json",
318 Some("css") => "text/css; charset=utf-8",
319 Some("js") => "application/javascript; charset=utf-8",
320 Some("svg") => "image/svg+xml",
321 Some("png") => "image/png",
322 Some("jpg") | Some("jpeg") => "image/jpeg",
323 _ => "application/octet-stream",
324 }
325}
326/// Returns the canonical status line text used by the runtime for a bounded set of statuses.
327///
328/// # Parameters
329///
330/// - `status_code`: Input consumed by [`crate::http::http_status_text`].
331///
332/// # Returns
333///
334/// Returns the computed value produced by [`crate::http::http_status_text`].
335///
336/// # Examples
337///
338/// ```rust,ignore
339/// let _result = pharos_runtime_core::http::http_status_text(400);
340/// ```
341///
342/// # Related
343///
344/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
345pub fn http_status_text(status_code: u16) -> &'static str {
346 match status_code {
347 200 => "200 OK",
348 302 => "302 Found",
349 400 => "400 Bad Request",
350 401 => "401 Unauthorized",
351 403 => "403 Forbidden",
352 404 => "404 Not Found",
353 405 => "405 Method Not Allowed",
354 409 => "409 Conflict",
355 413 => "413 Payload Too Large",
356 424 => "424 Failed Dependency",
357 429 => "429 Too Many Requests",
358 502 => "502 Bad Gateway",
359 500 => "500 Internal Server Error",
360 503 => "503 Service Unavailable",
361 504 => "504 Gateway Timeout",
362 _ => "500 Internal Server Error",
363 }
364}
365
366/// Removes query and fragment data from a request target.
367///
368/// # Parameters
369///
370/// - `target`: Input consumed by [`crate::http::request_path_without_query`].
371///
372/// # Returns
373///
374/// Returns [`Some`] when [`crate::http::request_path_without_query`] can produce a value and [`None`] otherwise.
375///
376/// # Performance
377///
378/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::request_path_without_query`].
379///
380/// # Examples
381///
382/// ```rust,ignore
383/// let _result = pharos_runtime_core::http::request_path_without_query("/");
384/// ```
385///
386/// # Related
387///
388/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
389pub fn request_path_without_query(target: &str) -> Option<String> {
390 let path = target.split('?').next()?.split('#').next()?.trim();
391 (!path.is_empty()
392 && path.starts_with('/')
393 && !path.bytes().any(|byte| byte < 0x20 || byte == 0x7f))
394 .then(|| path.to_owned())
395}
396
397/// Applies the bounded set of request headers consumed by the runtime.
398///
399/// # Parameters
400///
401/// - `request`: Input consumed by [`crate::http::apply_known_header`].
402/// - `header_name`: Input consumed by [`crate::http::apply_known_header`].
403/// - `header_value`: Input consumed by [`crate::http::apply_known_header`].
404/// - `content_length`: Input consumed by [`crate::http::apply_known_header`].
405/// - `chunked_transfer`: Input consumed by [`crate::http::apply_known_header`].
406///
407/// # Returns
408///
409/// Returns after [`crate::http::apply_known_header`] applies its in-place effect.
410///
411/// # Examples
412///
413/// ```rust,ignore
414/// let _result = pharos_runtime_core::http::apply_known_header(todo!(), "content-type", "application/json", todo!(), todo!());
415/// ```
416///
417/// # Related
418///
419/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
420pub fn apply_known_header(
421 request: &mut HttpRequest,
422 header_name: &str,
423 header_value: &str,
424 content_length: &mut i64,
425 chunked_transfer: &mut bool,
426) {
427 match header_name.to_ascii_lowercase().as_str() {
428 "content-length" => {
429 *content_length = content_length_value(header_value).unwrap_or(-1);
430 }
431 "content-type" => request.content_type = Some(header_value.trim().to_owned()),
432 "cookie" => request.cookie = Some(header_value.trim().to_owned()),
433 "transfer-encoding" if contains_chunked_transfer(header_value) => {
434 *chunked_transfer = true;
435 }
436 _ => {}
437 }
438}
439
440/// Parses a bounded HTTP request head without accepting folded or malformed headers.
441///
442/// # Parameters
443///
444/// - `bytes`: Input consumed by [`crate::http::parse_http_request`].
445///
446/// # Returns
447///
448/// Returns the successful value produced by [`crate::http::parse_http_request`] or an error when the operation fails.
449///
450/// # Errors
451///
452/// Returns an error value when [`crate::http::parse_http_request`] cannot complete successfully.
453///
454/// # Performance
455///
456/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::parse_http_request`].
457///
458/// # Examples
459///
460/// ```rust,ignore
461/// let _result = pharos_runtime_core::http::parse_http_request(b"");
462/// ```
463///
464/// # Related
465///
466/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
467pub fn parse_http_request(bytes: &[u8]) -> Result<ParsedHttpRequest, &'static str> {
468 if bytes.is_empty() || bytes.len() > PHAROS_HTTP_MAX_HEADER_BYTES {
469 return Err("HTTP header block exceeds its limit");
470 }
471 let text = std::str::from_utf8(bytes).map_err(|_| "HTTP header block is not UTF-8")?;
472 let mut lines = text.split("\r\n");
473 let request_line = lines.next().ok_or("HTTP request line is missing")?;
474 let mut fields = request_line.split_whitespace();
475 let method = fields.next().ok_or("HTTP method is missing")?;
476 let target = fields.next().ok_or("HTTP target is missing")?;
477 let version = fields.next().ok_or("HTTP version is missing")?;
478 if !matches!(version, "HTTP/1.0" | "HTTP/1.1")
479 || fields.next().is_some()
480 || !matches!(
481 method,
482 "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"
483 )
484 || method.len() > 16
485 || target.len() > PHAROS_HTTP_MAX_REQUEST_LINE_BYTES
486 || !target.starts_with('/')
487 || target
488 .bytes()
489 .any(|byte| byte < 0x20 || byte == 0x7f || byte == b'\\')
490 {
491 return Err("HTTP request line is invalid");
492 }
493 let mut headers = Vec::new();
494 for line in lines {
495 if line.is_empty() {
496 break;
497 }
498 if line.len() > PHAROS_HTTP_MAX_HEADER_LINE_BYTES {
499 return Err("HTTP header line exceeds its limit");
500 }
501 let (name, value) = line.split_once(':').ok_or("HTTP header is malformed")?;
502 if name.is_empty()
503 || name
504 .bytes()
505 .any(|byte| !byte.is_ascii_alphanumeric() && !matches!(byte, b'-'))
506 {
507 return Err("HTTP header name is invalid");
508 }
509 headers.push((name.to_ascii_lowercase(), value.trim().to_owned()));
510 if headers.len() > 256 {
511 return Err("HTTP header count exceeds its limit");
512 }
513 }
514 Ok(ParsedHttpRequest {
515 method: method.to_owned(),
516 target: target.to_owned(),
517 headers,
518 })
519}
520
521/// Extracts a response status code from an HTTP status line.
522///
523/// # Parameters
524///
525/// - `response`: Input consumed by [`crate::http::http_status_code_from_response`].
526///
527/// # Returns
528///
529/// Returns the computed value produced by [`crate::http::http_status_code_from_response`].
530///
531/// # Performance
532///
533/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::http_status_code_from_response`].
534///
535/// # Examples
536///
537/// ```rust,ignore
538/// let _result = pharos_runtime_core::http::http_status_code_from_response(todo!());
539/// ```
540///
541/// # Related
542///
543/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
544pub fn http_status_code_from_response(response: &str) -> u16 {
545 response
546 .lines()
547 .next()
548 .and_then(|line| line.split_whitespace().nth(1))
549 .and_then(|value| value.parse::<u16>().ok())
550 .unwrap_or(0)
551}
552
553/// Returns the complete first status line from an HTTP response.
554///
555/// # Parameters
556///
557/// - `response`: Input consumed by [`crate::http::http_status_line_from_response`].
558///
559/// # Returns
560///
561/// Returns [`Some`] when [`crate::http::http_status_line_from_response`] can produce a value and [`None`] otherwise.
562///
563/// # Performance
564///
565/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::http_status_line_from_response`].
566///
567/// # Examples
568///
569/// ```rust,ignore
570/// let _result = pharos_runtime_core::http::http_status_line_from_response(todo!());
571/// ```
572///
573/// # Related
574///
575/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
576pub fn http_status_line_from_response(response: &str) -> Option<String> {
577 let line = response.lines().next()?.trim();
578 (line.starts_with("HTTP/") && http_status_code_from_response(response) != 0)
579 .then(|| line.to_owned())
580}
581
582/// Performs sensitive headers mask.
583///
584/// # Parameters
585///
586/// - `headers`: Input consumed by [`crate::http::sensitive_headers_mask`].
587///
588/// # Returns
589///
590/// Returns [`Some`] when [`crate::http::sensitive_headers_mask`] can produce a value and [`None`] otherwise.
591///
592/// # Performance
593///
594/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::sensitive_headers_mask`].
595///
596/// # Examples
597///
598/// ```rust,ignore
599/// let _result = pharos_runtime_core::http::sensitive_headers_mask("host: example");
600/// ```
601///
602/// # Related
603///
604/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
605pub fn sensitive_headers_mask(headers: &str) -> Option<String> {
606 if headers.is_empty() {
607 return Some(String::new());
608 }
609 let mut out = String::new();
610 for raw_line in headers.lines() {
611 let line = raw_line.trim();
612 if let Some((name_raw, value_raw)) = line.split_once(':') {
613 let name = name_raw.trim();
614 let value = value_raw.trim();
615 if is_sensitive_header_name(name) {
616 let prefix_len = value.len().min(4);
617 out.push_str(name);
618 out.push_str(": [REDACTED: ");
619 out.push_str(&value[..prefix_len]);
620 out.push_str("]\n");
621 } else {
622 out.push_str(name_raw);
623 out.push(':');
624 out.push_str(value_raw);
625 out.push('\n');
626 }
627 } else {
628 out.push_str(line);
629 out.push('\n');
630 }
631 }
632 Some(out)
633}
634
635/// Compares two configured log levels without accepting empty values.
636///
637/// # Parameters
638///
639/// - `level`: Input consumed by [`crate::http::log_level_matches`].
640/// - `target`: Input consumed by [`crate::http::log_level_matches`].
641///
642/// # Returns
643///
644/// Returns `true` when the condition checked by [`crate::http::log_level_matches`] holds and `false` otherwise.
645///
646/// # Examples
647///
648/// ```rust,ignore
649/// let _result = pharos_runtime_core::http::log_level_matches("info", "/");
650/// ```
651///
652/// # Related
653///
654/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
655pub fn log_level_matches(level: &str, target: &str) -> bool {
656 !level.is_empty() && !target.is_empty() && level.eq_ignore_ascii_case(target)
657}
658
659/// Compacts one log line while preserving bounded UTF-8 text.
660///
661/// # Parameters
662///
663/// - `text`: Input consumed by [`crate::http::compact_log_text`].
664///
665/// # Returns
666///
667/// Returns the computed value produced by [`crate::http::compact_log_text`].
668///
669/// # Performance
670///
671/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::compact_log_text`].
672///
673/// # Examples
674///
675/// ```rust,ignore
676/// let _result = pharos_runtime_core::http::compact_log_text("{}");
677/// ```
678///
679/// # Related
680///
681/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
682pub fn compact_log_text(text: &str) -> String {
683 text.lines()
684 .map(str::trim)
685 .filter(|line| !line.is_empty())
686 .collect::<Vec<_>>()
687 .join(" ")
688 .chars()
689 .take(16 * 1024)
690 .collect()
691}
692
693/// Returns a stable UTC-like access timestamp in seconds.
694///
695/// # Returns
696///
697/// Returns the computed value produced by [`crate::http::access_timestamp`].
698///
699/// # Examples
700///
701/// ```rust,ignore
702/// let _result = pharos_runtime_core::http::access_timestamp();
703/// ```
704///
705/// # Related
706///
707/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
708pub fn access_timestamp() -> String {
709 std::time::SystemTime::now()
710 .duration_since(std::time::UNIX_EPOCH)
711 .map(|value| value.as_secs().to_string())
712 .unwrap_or_else(|_| "0".to_owned())
713}
714
715/// Closes an optional log file explicitly.
716///
717/// # Parameters
718///
719/// - `file`: Input consumed by [`crate::http::close_logged_file`].
720///
721/// # Returns
722///
723/// Returns `true` when the condition checked by [`crate::http::close_logged_file`] holds and `false` otherwise.
724///
725/// # Examples
726///
727/// ```rust,ignore
728/// let _result = pharos_runtime_core::http::close_logged_file(todo!());
729/// ```
730///
731/// # Related
732///
733/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
734pub fn close_logged_file(file: Option<File>) -> bool {
735 drop(file);
736 true
737}
738
739/// Resolves the configured error report path, restricting relative paths to the app root.
740///
741/// # Parameters
742///
743/// - `app_id`: Input consumed by [`crate::http::resolved_error_report_path`].
744/// - `configured_path`: Input consumed by [`crate::http::resolved_error_report_path`].
745///
746/// # Returns
747///
748/// Returns [`Some`] when [`crate::http::resolved_error_report_path`] can produce a value and [`None`] otherwise.
749///
750/// # Performance
751///
752/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::resolved_error_report_path`].
753///
754/// # Examples
755///
756/// ```rust,ignore
757/// let _result = pharos_runtime_core::http::resolved_error_report_path(todo!(), todo!());
758/// ```
759///
760/// # Related
761///
762/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
763pub fn resolved_error_report_path(
764 app_id: Option<&str>,
765 configured_path: Option<&Path>,
766) -> Option<String> {
767 let app_id = app_id.filter(|value| !value.is_empty()).unwrap_or("app");
768 if app_id
769 .bytes()
770 .any(|byte| !byte.is_ascii_alphanumeric() && byte != b'_' && byte != b'-')
771 {
772 return None;
773 }
774 let path = configured_path
775 .filter(|path| !path.as_os_str().is_empty())
776 .map(Path::to_path_buf)
777 .unwrap_or_else(|| Path::new("/tmp/pharos").join(format!("{app_id}.error.jsonl")));
778 if path.as_os_str().to_string_lossy().contains("..") {
779 return None;
780 }
781 Some(path.to_string_lossy().into_owned())
782}
783
784/// Writes one structured error report as an append-only JSONL record.
785///
786/// # Parameters
787///
788/// - `path`: Input consumed by [`crate::http::write_error_report`].
789/// - `report`: Input consumed by [`crate::http::write_error_report`].
790///
791/// # Returns
792///
793/// Returns the computed value produced by [`crate::http::write_error_report`].
794///
795/// # Examples
796///
797/// ```rust,ignore
798/// let _result = pharos_runtime_core::http::write_error_report(std::path::Path::new("fixture.json"), todo!());
799/// ```
800///
801/// # Related
802///
803/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
804pub fn write_error_report(path: &Path, report: &serde_json::Value) -> i32 {
805 let Some(parent) = path.parent() else {
806 return 69;
807 };
808 if std::fs::create_dir_all(parent).is_err() {
809 return 69;
810 }
811 let Ok(text) = serde_json::to_string(report) else {
812 return 75;
813 };
814 let record = format!("{text}\n");
815 crate::fs::append_file_within(
816 parent,
817 path,
818 record.as_bytes(),
819 crate::fs::PRIVATE_FILE_MODE,
820 )
821 .map(|_| 0)
822 .unwrap_or(69)
823}
824
825/// Returns the first forwarded client address when it is a valid IP literal.
826///
827/// # Parameters
828///
829/// - `value`: Input consumed by [`crate::http::extract_forwarded_ip`].
830///
831/// # Returns
832///
833/// Returns [`Some`] when [`crate::http::extract_forwarded_ip`] can produce a value and [`None`] otherwise.
834///
835/// # Performance
836///
837/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::extract_forwarded_ip`].
838///
839/// # Examples
840///
841/// ```rust,ignore
842/// let _result = pharos_runtime_core::http::extract_forwarded_ip("value");
843/// ```
844///
845/// # Related
846///
847/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
848pub fn extract_forwarded_ip(value: &str) -> Option<String> {
849 value.split(',').map(str::trim).find_map(|candidate| {
850 candidate
851 .parse::<IpAddr>()
852 .ok()
853 .map(|_| candidate.to_owned())
854 })
855}
856
857/// Extracts an address from peer information without trusting arbitrary text.
858///
859/// # Parameters
860///
861/// - `value`: Input consumed by [`crate::http::extract_ip_from_peer_info`].
862///
863/// # Returns
864///
865/// Returns [`Some`] when [`crate::http::extract_ip_from_peer_info`] can produce a value and [`None`] otherwise.
866///
867/// # Performance
868///
869/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::extract_ip_from_peer_info`].
870///
871/// # Examples
872///
873/// ```rust,ignore
874/// let _result = pharos_runtime_core::http::extract_ip_from_peer_info("value");
875/// ```
876///
877/// # Related
878///
879/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
880pub fn extract_ip_from_peer_info(value: &str) -> Option<String> {
881 value.split([' ', '\t', '\n', ':']).find_map(|candidate| {
882 candidate
883 .parse::<IpAddr>()
884 .ok()
885 .map(|_| candidate.to_owned())
886 })
887}
888
889/// Selects the real client IP from trusted forwarded headers or peer data.
890///
891/// # Parameters
892///
893/// - `headers`: Input consumed by [`crate::http::extract_real_ip_from_headers`].
894/// - `peer_info`: Input consumed by [`crate::http::extract_real_ip_from_headers`].
895/// - `trusted_proxy`: Input consumed by [`crate::http::extract_real_ip_from_headers`].
896///
897/// # Returns
898///
899/// Returns [`Some`] when [`crate::http::extract_real_ip_from_headers`] can produce a value and [`None`] otherwise.
900///
901/// # Performance
902///
903/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::extract_real_ip_from_headers`].
904///
905/// # Examples
906///
907/// ```rust,ignore
908/// let _result = pharos_runtime_core::http::extract_real_ip_from_headers("host: example", todo!(), todo!());
909/// ```
910///
911/// # Related
912///
913/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
914pub fn extract_real_ip_from_headers(
915 headers: &str,
916 peer_info: Option<&str>,
917 trusted_proxy: bool,
918) -> Option<String> {
919 if trusted_proxy
920 && let Some(value) = headers
921 .lines()
922 .find_map(|line| {
923 let (name, value) = line.split_once(':')?;
924 name.trim()
925 .eq_ignore_ascii_case("x-forwarded-for")
926 .then_some(value.trim())
927 })
928 .and_then(extract_forwarded_ip)
929 {
930 return Some(value);
931 }
932 peer_info.and_then(extract_ip_from_peer_info)
933}
934
935/// Checks whether a direct peer address belongs to one of the configured CIDRs.
936///
937/// # Parameters
938///
939/// - `peer_ip`: Input consumed by [`crate::http::is_trusted_proxy`].
940/// - `trusted_proxy_cidrs`: Input consumed by [`crate::http::is_trusted_proxy`].
941///
942/// # Returns
943///
944/// Returns `true` when the condition checked by [`crate::http::is_trusted_proxy`] holds and `false` otherwise.
945///
946/// # Performance
947///
948/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::is_trusted_proxy`].
949///
950/// # Examples
951///
952/// ```rust,ignore
953/// let _result = pharos_runtime_core::http::is_trusted_proxy("127.0.0.1", "127.0.0.1/32");
954/// ```
955///
956/// # Related
957///
958/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
959pub fn is_trusted_proxy(peer_ip: &str, trusted_proxy_cidrs: &str) -> bool {
960 let Ok(peer) = peer_ip.parse::<IpAddr>() else {
961 return false;
962 };
963 trusted_proxy_cidrs
964 .split(',')
965 .map(str::trim)
966 .filter(|value| !value.is_empty())
967 .any(|cidr| {
968 let Some((network, bits)) = cidr.split_once('/') else {
969 return false;
970 };
971 let Ok(network) = network.parse::<IpAddr>() else {
972 return false;
973 };
974 let Ok(bits) = bits.parse::<u8>() else {
975 return false;
976 };
977 match (peer, network) {
978 (IpAddr::V4(peer), IpAddr::V4(network)) if bits <= 32 => {
979 let mask = if bits == 0 {
980 0
981 } else {
982 u32::MAX << (32 - bits)
983 };
984 u32::from(peer) & mask == u32::from(network) & mask
985 }
986 (IpAddr::V6(peer), IpAddr::V6(network)) if bits <= 128 => {
987 let peer = u128::from_be_bytes(peer.octets());
988 let network = u128::from_be_bytes(network.octets());
989 let mask = if bits == 0 {
990 0
991 } else {
992 u128::MAX << (128 - bits)
993 };
994 peer & mask == network & mask
995 }
996 _ => false,
997 }
998 })
999}
1000
1001/// Returns a client address selected from the request's trusted proxy context.
1002///
1003/// # Parameters
1004///
1005/// - `headers`: Input consumed by [`crate::http::request_real_ip`].
1006/// - `peer_info`: Input consumed by [`crate::http::request_real_ip`].
1007/// - `trusted_proxy_cidrs`: Input consumed by [`crate::http::request_real_ip`].
1008///
1009/// # Returns
1010///
1011/// Returns [`Some`] when [`crate::http::request_real_ip`] can produce a value and [`None`] otherwise.
1012///
1013/// # Examples
1014///
1015/// ```rust,ignore
1016/// let _result = pharos_runtime_core::http::request_real_ip("host: example", todo!(), "127.0.0.1/32");
1017/// ```
1018///
1019/// # Related
1020///
1021/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
1022pub fn request_real_ip(
1023 headers: &str,
1024 peer_info: Option<&str>,
1025 trusted_proxy_cidrs: Option<&str>,
1026) -> Option<String> {
1027 let peer = peer_info.and_then(extract_ip_from_peer_info)?;
1028 let trusted = trusted_proxy_cidrs.is_some_and(|cidrs| is_trusted_proxy(&peer, cidrs));
1029 extract_real_ip_from_headers(headers, Some(&peer), trusted)
1030}
1031
1032/// Redacts sensitive request headers before they enter an access log.
1033///
1034/// # Parameters
1035///
1036/// - `headers`: Input consumed by [`crate::http::sensitive_headers_redact`].
1037///
1038/// # Returns
1039///
1040/// Returns [`Some`] when [`crate::http::sensitive_headers_redact`] can produce a value and [`None`] otherwise.
1041///
1042/// # Performance
1043///
1044/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::http::sensitive_headers_redact`].
1045///
1046/// # Examples
1047///
1048/// ```rust,ignore
1049/// let _result = pharos_runtime_core::http::sensitive_headers_redact("host: example");
1050/// ```
1051///
1052/// # Related
1053///
1054/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
1055pub fn sensitive_headers_redact(headers: &str) -> Option<String> {
1056 let mut result = String::new();
1057 for line in headers.lines() {
1058 let Some((name, value)) = line.split_once(':') else {
1059 result.push_str(line);
1060 result.push('\n');
1061 continue;
1062 };
1063 result.push_str(name.trim());
1064 result.push_str(": ");
1065 if is_sensitive_header_name(name.trim()) {
1066 result.push_str("[REDACTED]");
1067 } else {
1068 result.push_str(value.trim());
1069 }
1070 result.push('\n');
1071 }
1072 Some(result)
1073}
1074
1075/// Returns request headers with a validated client-address line appended.
1076///
1077/// # Parameters
1078///
1079/// - `headers`: Input consumed by [`crate::http::request_headers_with_client_ip`].
1080/// - `client_ip`: Input consumed by [`crate::http::request_headers_with_client_ip`].
1081///
1082/// # Returns
1083///
1084/// Returns the computed value produced by [`crate::http::request_headers_with_client_ip`].
1085///
1086/// # Examples
1087///
1088/// ```rust,ignore
1089/// let _result = pharos_runtime_core::http::request_headers_with_client_ip("host: example", todo!());
1090/// ```
1091///
1092/// # Related
1093///
1094/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
1095pub fn request_headers_with_client_ip(headers: &str, client_ip: Option<&str>) -> String {
1096 let mut output = headers.to_owned();
1097 if let Some(client_ip) = client_ip.filter(|value| value.parse::<IpAddr>().is_ok()) {
1098 if !output.is_empty() && !output.ends_with('\n') {
1099 output.push('\n');
1100 }
1101 output.push_str("X-Client-IP: ");
1102 output.push_str(client_ip);
1103 output.push('\n');
1104 }
1105 output
1106}
1107
1108/// Parses an IPv4 address into its network-order integer representation.
1109///
1110/// # Parameters
1111///
1112/// - `value`: Input consumed by [`crate::http::parse_ip_to_uint32`].
1113///
1114/// # Returns
1115///
1116/// Returns [`Some`] when [`crate::http::parse_ip_to_uint32`] can produce a value and [`None`] otherwise.
1117///
1118/// # Examples
1119///
1120/// ```rust,ignore
1121/// let _result = pharos_runtime_core::http::parse_ip_to_uint32("value");
1122/// ```
1123///
1124/// # Related
1125///
1126/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
1127pub fn parse_ip_to_uint32(value: &str) -> Option<u32> {
1128 match value.parse::<std::net::Ipv4Addr>() {
1129 Ok(value) => Some(u32::from(value)),
1130 Err(_) => None,
1131 }
1132}
1133
1134/// Builds a text response wire representation with a bounded body.
1135///
1136/// # Parameters
1137///
1138/// - `status`: Input consumed by [`crate::http::send_text_response`].
1139/// - `status_text`: Input consumed by [`crate::http::send_text_response`].
1140/// - `body`: Input consumed by [`crate::http::send_text_response`].
1141///
1142/// # Returns
1143///
1144/// Returns the computed value produced by [`crate::http::send_text_response`].
1145///
1146/// # Examples
1147///
1148/// ```rust,ignore
1149/// let _result = pharos_runtime_core::http::send_text_response(400, "400 Bad Request", b"");
1150/// ```
1151///
1152/// # Related
1153///
1154/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
1155pub fn send_text_response(status: u16, status_text: &str, body: &str) -> String {
1156 let body = body
1157 .as_bytes()
1158 .get(..body.len().min(PHAROS_HTTP_MAX_BODY_BYTES))
1159 .and_then(|bytes| std::str::from_utf8(bytes).ok())
1160 .unwrap_or("");
1161 format!(
1162 "HTTP/1.1 {status} {}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1163 if status_text.is_empty() {
1164 http_status_text(status)
1165 } else {
1166 status_text
1167 },
1168 body.len()
1169 )
1170}
1171
1172/// Appends a formatted text fragment to a bounded connection response buffer.
1173///
1174/// # Parameters
1175///
1176/// - `output`: Input consumed by [`crate::http::connection_printf`].
1177/// - `fragment`: Input consumed by [`crate::http::connection_printf`].
1178///
1179/// # Returns
1180///
1181/// Returns `true` when the condition checked by [`crate::http::connection_printf`] holds and `false` otherwise.
1182///
1183/// # Examples
1184///
1185/// ```rust,ignore
1186/// let _result = pharos_runtime_core::http::connection_printf(todo!(), todo!());
1187/// ```
1188///
1189/// # Related
1190///
1191/// - [`crate::http`] for adjacent helpers that share the same runtime surface.
1192pub fn connection_printf(output: &mut String, fragment: &str) -> bool {
1193 if output.len().saturating_add(fragment.len()) > 16 * 1024 * 1024 {
1194 return false;
1195 }
1196 output.push_str(fragment);
1197 true
1198}
1199
1200#[cfg(test)]
1201mod parser_tests {
1202 use super::parse_http_request;
1203
1204 /// Exercises a deterministic malformed corpus against the only active request-head parser.
1205 #[test]
1206 fn hostile_request_corpus_fails_closed() {
1207 let malformed = [
1208 b"".as_slice(),
1209 b"TRACE / HTTP/1.1\r\n\r\n".as_slice(),
1210 b"GET relative HTTP/1.1\r\n\r\n".as_slice(),
1211 b"GET /\\escape HTTP/1.1\r\n\r\n".as_slice(),
1212 b"GET / HTTP/9.9\r\n\r\n".as_slice(),
1213 b"GET / HTTP/1.1 extra\r\n\r\n".as_slice(),
1214 b"GET / HTTP/1.1\r\n folded: value\r\n\r\n".as_slice(),
1215 b"GET / HTTP/1.1\r\nBad Header: value\r\n\r\n".as_slice(),
1216 b"GET / HTTP/1.1\r\nMissing-Colon\r\n\r\n".as_slice(),
1217 ];
1218 for request in malformed {
1219 assert!(parse_http_request(request).is_err(), "accepted {request:?}");
1220 }
1221 assert!(
1222 parse_http_request(b"GET /ready?probe=1 HTTP/1.1\r\nHost: localhost\r\n\r\n").is_ok()
1223 );
1224 }
1225
1226 /// Allows browser return requests with larger cookie headers while retaining bounded parsing.
1227 #[test]
1228 fn browser_return_request_accepts_large_cookie_header() {
1229 let cookie_value = "s".repeat(12 * 1024);
1230 let request = format!(
1231 "GET /ucal/appointments/?session_id=cs_test_return HTTP/1.1\r\nHost: 127.0.0.1:8084\r\nCookie: pharos_session={cookie_value}\r\n\r\n"
1232 );
1233 let parsed = parse_http_request(request.as_bytes()).expect("large browser return header");
1234 assert_eq!(parsed.method, "GET");
1235 assert_eq!(
1236 parsed.target,
1237 "/ucal/appointments/?session_id=cs_test_return"
1238 );
1239 assert!(
1240 parsed
1241 .headers
1242 .iter()
1243 .any(|(name, value)| name == "cookie" && value.len() > 12_000)
1244 );
1245 }
1246}