Skip to main content

pharos_runtime_static/
artifact_server.rs

1#![allow(clippy::result_unit_err)]
2
3use std::fs::{self, File};
4use std::io::{Read, Seek, SeekFrom, Write};
5use std::path::{Component, Path, PathBuf};
6
7const MAX_REQUEST_TARGET_BYTES: usize = 8 * 1024;
8const MAX_ROUTE_LINE_BYTES: usize = 16 * 1024;
9const MAX_STATIC_FILE_BYTES: u64 = 16 * 1024 * 1024;
10
11#[derive(Debug, Clone, Eq, PartialEq)]
12/// A validated file slice that can be copied to a connection with bounded memory.
13///
14/// # Fields
15///
16/// - `status_code`: Stored field on `StreamResponse`.
17/// - `status_text`: Stored field on `StreamResponse`.
18/// - `content_type`: Stored field on `StreamResponse`.
19/// - `file_path`: Stored field on `StreamResponse`.
20/// - `offset`: Stored field on `StreamResponse`.
21/// - `content_length`: Stored field on `StreamResponse`.
22/// - `complete_length`: Stored field on `StreamResponse`.
23/// - `etag`: Stored field on `StreamResponse`.
24/// - `cache_control`: Stored field on `StreamResponse`.
25/// - `content_disposition`: Stored field on `StreamResponse`.
26///
27/// # Examples
28///
29/// ```rust,ignore
30/// let _ = std::mem::size_of::<pharos_runtime_static::artifact_server::StreamResponse>();
31/// ```
32///
33/// # Related
34///
35/// - [`crate::artifact_server`] for constructors, helpers, and neighboring runtime types.
36pub struct StreamResponse {
37    /// HTTP status code.
38    pub status_code: u16,
39    /// HTTP reason phrase.
40    pub status_text: &'static str,
41    /// MIME type selected from the trusted asset filename.
42    pub content_type: &'static str,
43    /// Canonical file path within the artifact boundary.
44    pub file_path: PathBuf,
45    /// First byte included in the response.
46    pub offset: u64,
47    /// Number of bytes included in the response.
48    pub content_length: u64,
49    /// Complete file length used by `Content-Range`.
50    pub complete_length: u64,
51    /// Strong local validator for `If-Range`.
52    pub etag: String,
53    /// Cache policy selected by the runtime, not the application.
54    pub cache_control: &'static str,
55    /// Trusted attachment filename for protected downloads.
56    pub content_disposition: Option<String>,
57}
58
59#[derive(Debug, Clone, Eq, PartialEq)]
60/// A bounded static error plus optional unsatisfied representation length.
61///
62/// # Fields
63///
64/// - `response`: Stored field on `StreamError`.
65/// - `unsatisfied_length`: Stored field on `StreamError`.
66///
67/// # Examples
68///
69/// ```rust,ignore
70/// let _ = std::mem::size_of::<pharos_runtime_static::artifact_server::StreamError>();
71/// ```
72///
73/// # Related
74///
75/// - [`crate::artifact_server`] for constructors, helpers, and neighboring runtime types.
76pub struct StreamError {
77    /// Existing bounded error body.
78    pub response: StaticResponse,
79    /// Complete length emitted as `Content-Range: bytes */N` for status 416.
80    pub unsatisfied_length: Option<u64>,
81}
82
83/// Wraps one ordinary static error without range metadata.
84fn stream_error(response: StaticResponse) -> StreamError {
85    StreamError {
86        response,
87        unsatisfied_length: None,
88    }
89}
90
91#[derive(Debug, Clone, Eq, PartialEq)]
92/// A bounded static response selected from one packaged app artifact.
93///
94/// # Fields
95///
96/// - `status_code`: Stored field on `StaticResponse`.
97/// - `status_text`: Stored field on `StaticResponse`.
98/// - `content_type`: Stored field on `StaticResponse`.
99/// - `body`: Stored field on `StaticResponse`.
100/// - `content_length`: Stored field on `StaticResponse`.
101///
102/// # Examples
103///
104/// ```rust,ignore
105/// let _ = std::mem::size_of::<pharos_runtime_static::artifact_server::StaticResponse>();
106/// ```
107///
108/// # Related
109///
110/// - [`crate::artifact_server`] for constructors, helpers, and neighboring runtime types.
111pub struct StaticResponse {
112    /// HTTP status code.
113    pub status_code: u16,
114    /// HTTP reason phrase.
115    pub status_text: &'static str,
116    /// MIME type for the response body.
117    pub content_type: &'static str,
118    /// Response bytes. Empty for `HEAD` responses.
119    pub body: Vec<u8>,
120    /// Original body length, retained for `HEAD` and header generation.
121    pub content_length: u64,
122}
123
124/// Performs response.
125fn response(
126    status_code: u16,
127    status_text: &'static str,
128    content_type: &'static str,
129    body: Vec<u8>,
130    content_length: u64,
131) -> StaticResponse {
132    StaticResponse {
133        status_code,
134        status_text,
135        content_type,
136        body,
137        content_length,
138    }
139}
140
141/// Performs text error.
142fn text_error(
143    status_code: u16,
144    status_text: &'static str,
145    message: &'static str,
146) -> StaticResponse {
147    let code = pharos_runtime_core::error::ErrorCode::from_http_status(status_code);
148    let body = pharos_runtime_core::error::envelope(code, message.trim(), None).to_string();
149    response(
150        status_code,
151        status_text,
152        "application/json; charset=utf-8",
153        body.as_bytes().to_vec(),
154        body.len() as u64,
155    )
156}
157
158/// Performs percent decode path.
159fn percent_decode_path(path: &str) -> Option<String> {
160    let bytes = path.as_bytes();
161    let mut decoded = Vec::with_capacity(bytes.len());
162    let mut index = 0usize;
163    while index < bytes.len() {
164        if bytes[index] == b'%' {
165            if index + 2 >= bytes.len() {
166                return None;
167            }
168            let byte = u8::from_str_radix(&path[index + 1..index + 3], 16).ok()?;
169            decoded.push(byte);
170            index += 3;
171        } else {
172            decoded.push(bytes[index]);
173            index += 1;
174        }
175    }
176    let decoded = String::from_utf8(decoded).ok()?;
177    if decoded
178        .bytes()
179        .any(|byte| byte == 0 || byte < 0x20 || byte == 0x7f)
180    {
181        return None;
182    }
183    Some(decoded)
184}
185
186/// Returns whether a request path contains a parent-directory component.
187///
188/// # Parameters
189///
190/// - `path`: Input consumed by [`crate::artifact_server::path_has_parent_reference`].
191///
192/// # Returns
193///
194/// Returns `true` when the condition checked by [`crate::artifact_server::path_has_parent_reference`] holds and `false` otherwise.
195///
196/// # Performance
197///
198/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::artifact_server::path_has_parent_reference`].
199///
200/// # Examples
201///
202/// ```rust,ignore
203/// let _result = pharos_runtime_static::artifact_server::path_has_parent_reference(std::path::Path::new("fixture.json"));
204/// ```
205///
206/// # Related
207///
208/// - [`crate::artifact_server`] for adjacent helpers that share the same runtime surface.
209pub fn path_has_parent_reference(path: &str) -> bool {
210    path.split(['/', '\\']).any(|segment| segment == "..")
211}
212
213/// Performs normalized path.
214fn normalized_path(target: &str) -> Option<String> {
215    if target.is_empty() || target.len() > MAX_REQUEST_TARGET_BYTES {
216        return None;
217    }
218    let raw_path = target.split(['?', '#']).next().unwrap_or_default();
219    if !raw_path.starts_with('/') {
220        return None;
221    }
222    let decoded = percent_decode_path(raw_path)?;
223    if decoded.contains('\\') {
224        return None;
225    }
226    let mut normalized = String::from("/");
227    let mut first = true;
228    for component in Path::new(&decoded).components() {
229        match component {
230            Component::RootDir | Component::CurDir => {}
231            Component::ParentDir => return None,
232            Component::Normal(segment) => {
233                let segment = segment.to_str()?;
234                if segment.is_empty() {
235                    continue;
236                }
237                if !first {
238                    normalized.push('/');
239                }
240                normalized.push_str(segment);
241                first = false;
242            }
243            Component::Prefix(_) => return None,
244        }
245    }
246    Some(normalized)
247}
248
249/// Parses the content type for path.
250fn content_type_for_path(path: &Path) -> &'static str {
251    match path.extension().and_then(|extension| extension.to_str()) {
252        Some("html") => "text/html; charset=utf-8",
253        Some("json") => "application/json",
254        Some("css") => "text/css; charset=utf-8",
255        Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
256        Some("svg") => "image/svg+xml",
257        Some("woff2") => "font/woff2",
258        Some("woff") => "font/woff",
259        Some("ttf") => "font/ttf",
260        Some("otf") => "font/otf",
261        Some("eot") => "application/vnd.ms-fontobject",
262        Some("png") => "image/png",
263        Some("jpg") | Some("jpeg") => "image/jpeg",
264        Some("gif") => "image/gif",
265        Some("webp") => "image/webp",
266        Some("ico") => "image/x-icon",
267        Some("mp3") => "audio/mpeg",
268        Some("wav") => "audio/wav",
269        Some("ogg") => "audio/ogg",
270        Some("m4a") => "audio/mp4",
271        Some("zip") => "application/zip",
272        Some("txt") => "text/plain; charset=utf-8",
273        _ => "application/octet-stream",
274    }
275}
276
277/// Parses one RFC 7233 byte range against a known representation length.
278fn byte_range(value: &str, length: u64) -> Result<(u64, u64), ()> {
279    let value = value.trim();
280    let raw = value.strip_prefix("bytes=").ok_or(())?;
281    if raw.is_empty() || raw.contains(',') || length == 0 {
282        return Err(());
283    }
284    let (start, end) = raw.split_once('-').ok_or(())?;
285    if start.is_empty() {
286        let suffix = end.parse::<u64>().map_err(|_| ())?;
287        if suffix == 0 {
288            return Err(());
289        }
290        let count = suffix.min(length);
291        return Ok((length - count, length - 1));
292    }
293    let start = start.parse::<u64>().map_err(|_| ())?;
294    if start >= length {
295        return Err(());
296    }
297    let end = if end.is_empty() {
298        length - 1
299    } else {
300        end.parse::<u64>().map_err(|_| ())?.min(length - 1)
301    };
302    (end >= start).then_some((start, end)).ok_or(())
303}
304
305/// Creates a deterministic validator without reading the representation body.
306fn file_etag(metadata: &fs::Metadata) -> String {
307    let modified = metadata
308        .modified()
309        .ok()
310        .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok())
311        .map(|value| value.as_nanos())
312        .unwrap_or_default();
313    format!("\"{:x}-{:x}\"", metadata.len(), modified)
314}
315
316/// Selects immutable caching only for filenames that visibly carry a content hash.
317fn cache_control_for_path(path: &Path) -> &'static str {
318    let stem = path
319        .file_stem()
320        .and_then(|value| value.to_str())
321        .unwrap_or("");
322    let hashed = stem
323        .split(['-', '.'])
324        .any(|part| part.len() >= 8 && part.bytes().all(|byte| byte.is_ascii_hexdigit()));
325    if hashed {
326        "public, max-age=31536000, immutable"
327    } else {
328        "public, no-cache"
329    }
330}
331
332/// Resolves a public static/media request into a bounded streaming plan.
333///
334/// # Parameters
335///
336/// - `root`: Input consumed by [`crate::artifact_server::serve_streamable_asset`].
337/// - `method`: Input consumed by [`crate::artifact_server::serve_streamable_asset`].
338/// - `target`: Input consumed by [`crate::artifact_server::serve_streamable_asset`].
339/// - `range`: Input consumed by [`crate::artifact_server::serve_streamable_asset`].
340/// - `if_range`: Input consumed by [`crate::artifact_server::serve_streamable_asset`].
341///
342/// # Returns
343///
344/// Returns the successful value produced by [`crate::artifact_server::serve_streamable_asset`] or an error when the operation fails.
345///
346/// # Errors
347///
348/// Returns an error value when [`crate::artifact_server::serve_streamable_asset`] cannot complete successfully.
349///
350/// # Examples
351///
352/// ```rust,ignore
353/// let _result = pharos_runtime_static::artifact_server::serve_streamable_asset(std::path::Path::new("."), todo!(), "/", todo!(), todo!());
354/// ```
355///
356/// # Related
357///
358/// - [`crate::artifact_server`] for adjacent helpers that share the same runtime surface.
359pub fn serve_streamable_asset(
360    root: &Path,
361    method: &str,
362    target: &str,
363    range: Option<&str>,
364    if_range: Option<&str>,
365) -> Result<Option<StreamResponse>, StreamError> {
366    if !matches!(method, "GET" | "HEAD") {
367        return Err(stream_error(text_error(
368            405,
369            "Method Not Allowed",
370            "method not allowed\n",
371        )));
372    }
373    if root.as_os_str().is_empty() || !root.is_dir() {
374        return Err(stream_error(text_error(
375            500,
376            "Internal Server Error",
377            "artifact root unavailable\n",
378        )));
379    }
380    let normalized = normalized_path(target)
381        .ok_or_else(|| stream_error(text_error(400, "Bad Request", "invalid request path\n")))?;
382    let Some((file_path, content_type)) = resolve_direct_asset(root, &normalized)
383        .map_err(|_| stream_error(text_error(400, "Bad Request", "invalid request path\n")))?
384    else {
385        return Ok(None);
386    };
387    let metadata = fs::metadata(&file_path)
388        .map_err(|_| stream_error(text_error(404, "Not Found", "not found\n")))?;
389    let complete_length = metadata.len();
390    let etag = file_etag(&metadata);
391    let selected_range = range.filter(|_| if_range.is_none_or(|value| value.trim() == etag));
392    let (status_code, status_text, offset, content_length) = match selected_range {
393        Some(value) => match byte_range(value, complete_length) {
394            Ok((start, end)) => (206, "Partial Content", start, end - start + 1),
395            Err(()) => {
396                return Err(StreamError {
397                    response: text_error(416, "Range Not Satisfiable", "invalid byte range\n"),
398                    unsatisfied_length: Some(complete_length),
399                });
400            }
401        },
402        None => (200, "OK", 0, complete_length),
403    };
404    Ok(Some(StreamResponse {
405        status_code,
406        status_text,
407        content_type,
408        file_path: file_path.clone(),
409        offset,
410        content_length,
411        complete_length,
412        etag,
413        cache_control: cache_control_for_path(&file_path),
414        content_disposition: None,
415    }))
416}
417
418/// Resolves one already-authorized private asset key into a streaming plan.
419///
420/// # Parameters
421///
422/// - `root`: Input consumed by [`crate::artifact_server::serve_private_asset`].
423/// - `private_root`: Input consumed by [`crate::artifact_server::serve_private_asset`].
424/// - `asset_key`: Input consumed by [`crate::artifact_server::serve_private_asset`].
425/// - `method`: Input consumed by [`crate::artifact_server::serve_private_asset`].
426/// - `range`: Input consumed by [`crate::artifact_server::serve_private_asset`].
427/// - `if_range`: Input consumed by [`crate::artifact_server::serve_private_asset`].
428///
429/// # Returns
430///
431/// Returns the successful value produced by [`crate::artifact_server::serve_private_asset`] or an error when the operation fails.
432///
433/// # Errors
434///
435/// Returns an error value when [`crate::artifact_server::serve_private_asset`] cannot complete successfully.
436///
437/// # Performance
438///
439/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::artifact_server::serve_private_asset`].
440///
441/// # Examples
442///
443/// ```rust,ignore
444/// let _result = pharos_runtime_static::artifact_server::serve_private_asset(std::path::Path::new("."), todo!(), todo!(), todo!(), todo!(), todo!());
445/// ```
446///
447/// # Related
448///
449/// - [`crate::artifact_server`] for adjacent helpers that share the same runtime surface.
450pub fn serve_private_asset(
451    root: &Path,
452    private_root: &str,
453    asset_key: &str,
454    method: &str,
455    range: Option<&str>,
456    if_range: Option<&str>,
457) -> Result<StreamResponse, StreamError> {
458    if !matches!(method, "GET" | "HEAD") {
459        return Err(stream_error(text_error(
460            405,
461            "Method Not Allowed",
462            "method not allowed\n",
463        )));
464    }
465    let private_root_path = safe_artifact_path(root, private_root)
466        .ok_or_else(|| stream_error(text_error(404, "Not Found", "not found\n")))?;
467    let file_path = pharos_runtime_core::safe_existing_artifact_path(&private_root_path, asset_key)
468        .filter(|path| path.is_file())
469        .ok_or_else(|| stream_error(text_error(404, "Not Found", "not found\n")))?;
470    let metadata = fs::metadata(&file_path)
471        .map_err(|_| stream_error(text_error(404, "Not Found", "not found\n")))?;
472    let complete_length = metadata.len();
473    let etag = file_etag(&metadata);
474    let selected_range = range.filter(|_| if_range.is_none_or(|value| value.trim() == etag));
475    let (status_code, status_text, offset, content_length) = match selected_range {
476        Some(value) => match byte_range(value, complete_length) {
477            Ok((start, end)) => (206, "Partial Content", start, end - start + 1),
478            Err(()) => {
479                return Err(StreamError {
480                    response: text_error(416, "Range Not Satisfiable", "invalid byte range\n"),
481                    unsatisfied_length: Some(complete_length),
482                });
483            }
484        },
485        None => (200, "OK", 0, complete_length),
486    };
487    let filename = file_path
488        .file_name()
489        .and_then(|value| value.to_str())
490        .filter(|value| {
491            !value.is_empty()
492                && value
493                    .bytes()
494                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
495        })
496        .map(ToOwned::to_owned)
497        .ok_or_else(|| stream_error(text_error(404, "Not Found", "not found\n")))?;
498    Ok(StreamResponse {
499        status_code,
500        status_text,
501        content_type: content_type_for_path(&file_path),
502        file_path,
503        offset,
504        content_length,
505        complete_length,
506        etag,
507        cache_control: "private, no-store",
508        content_disposition: Some(format!("attachment; filename=\"{filename}\"")),
509    })
510}
511
512/// Builds a safe artifact path.
513fn safe_artifact_path(root: &Path, relative: &str) -> Option<PathBuf> {
514    pharos_runtime_core::safe_existing_artifact_path(root, relative)
515}
516
517/// Resolves a direct `/static/` or `/media/` request under an artifact root.
518///
519/// # Parameters
520///
521/// - `root`: Input consumed by [`crate::artifact_server::resolve_direct_asset`].
522/// - `path`: Input consumed by [`crate::artifact_server::resolve_direct_asset`].
523/// - `) -> Result<Option<(PathBuf, 'static str)>, (`: Input consumed by [`crate::artifact_server::resolve_direct_asset`].
524///
525/// # Returns
526///
527/// Returns the successful value produced by [`crate::artifact_server::resolve_direct_asset`] or an error when the operation fails.
528///
529/// # Errors
530///
531/// Returns an error value when [`crate::artifact_server::resolve_direct_asset`] cannot complete successfully.
532///
533/// # Examples
534///
535/// ```rust,ignore
536/// let _result = pharos_runtime_static::artifact_server::resolve_direct_asset(std::path::Path::new("."), std::path::Path::new("fixture.json"), todo!());
537/// ```
538///
539/// # Related
540///
541/// - [`crate::artifact_server`] for adjacent helpers that share the same runtime surface.
542pub fn resolve_direct_asset(
543    root: &Path,
544    path: &str,
545) -> Result<Option<(PathBuf, &'static str)>, ()> {
546    let normalized = normalized_path(path).ok_or(())?;
547    let relative = normalized.strip_prefix('/').ok_or(())?;
548    if !(relative.starts_with("static/") || relative.starts_with("media/")) {
549        return Ok(None);
550    }
551    // Builder context attachments are private intake, never public media assets.
552    if relative.starts_with("media/attachments/") {
553        return Err(());
554    }
555    if let Some(media_path) = relative.strip_prefix("media/") {
556        let media_root = pharos_runtime_core::config::app_media_root(root).ok_or(())?;
557        if let Some(file) =
558            safe_artifact_path(&media_root, media_path).filter(|file| file.is_file())
559        {
560            let content_type = content_type_for_path(&file);
561            return Ok(Some((file, content_type)));
562        }
563    }
564    let file = safe_artifact_path(root, relative).ok_or(())?;
565    if !file.is_file() {
566        return Ok(None);
567    }
568    let content_type = content_type_for_path(&file);
569    Ok(Some((file, content_type)))
570}
571
572/// Looks up one GET route in the artifact route map.
573///
574/// # Parameters
575///
576/// - `root`: Input consumed by [`crate::artifact_server::lookup_route_map`].
577/// - `path`: Input consumed by [`crate::artifact_server::lookup_route_map`].
578///
579/// # Returns
580///
581/// Returns the successful value produced by [`crate::artifact_server::lookup_route_map`] or an error when the operation fails.
582///
583/// # Errors
584///
585/// Returns an error value when [`crate::artifact_server::lookup_route_map`] cannot complete successfully.
586///
587/// # Performance
588///
589/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::artifact_server::lookup_route_map`].
590///
591/// # Examples
592///
593/// ```rust,ignore
594/// let _result = pharos_runtime_static::artifact_server::lookup_route_map(std::path::Path::new("."), std::path::Path::new("fixture.json"));
595/// ```
596///
597/// # Related
598///
599/// - [`crate::artifact_server`] for adjacent helpers that share the same runtime surface.
600pub fn lookup_route_map(root: &Path, path: &str) -> Result<Option<(PathBuf, &'static str)>, ()> {
601    let normalized = normalized_path(path).ok_or(())?;
602    let map_path = safe_artifact_path(root, "runtime-route-map.tsv").ok_or(())?;
603    if !map_path.is_file() {
604        return Ok(None);
605    }
606    let text = fs::read_to_string(map_path).map_err(|_| ())?;
607    for line in text.lines() {
608        if line.len() > MAX_ROUTE_LINE_BYTES {
609            continue;
610        }
611        let mut fields = line.split('\t');
612        let method = fields.next().unwrap_or_default();
613        let route_path = fields.next().unwrap_or_default();
614        let artifact = fields.next().unwrap_or_default();
615        let content_type = fields.next().unwrap_or_default();
616        let _status = fields.next().unwrap_or_default();
617        if method != "GET"
618            || normalized_path(route_path).as_deref() != Some(normalized.as_str())
619            || artifact.is_empty()
620        {
621            continue;
622        }
623        let file = safe_artifact_path(root, artifact).ok_or(())?;
624        if !file.is_file() {
625            return Ok(None);
626        }
627        let content_type = match content_type {
628            "text/html; charset=utf-8" => "text/html; charset=utf-8",
629            "application/json" => "application/json",
630            "text/css; charset=utf-8" => "text/css; charset=utf-8",
631            "application/javascript; charset=utf-8" => "application/javascript; charset=utf-8",
632            "image/svg+xml" => "image/svg+xml",
633            _ => content_type_for_path(&file),
634        };
635        return Ok(Some((file, content_type)));
636    }
637    Ok(None)
638}
639
640/// Reads file.
641fn read_file(path: &Path) -> Result<(Vec<u8>, u64), ()> {
642    let mut file = File::open(path).map_err(|_| ())?;
643    let length = file.metadata().map_err(|_| ())?.len();
644    if length > MAX_STATIC_FILE_BYTES {
645        return Err(());
646    }
647    file.seek(SeekFrom::Start(0)).map_err(|_| ())?;
648    let mut body = Vec::with_capacity(length as usize);
649    file.take(MAX_STATIC_FILE_BYTES + 1)
650        .read_to_end(&mut body)
651        .map_err(|_| ())?;
652    if body.len() as u64 != length || body.len() as u64 > MAX_STATIC_FILE_BYTES {
653        return Err(());
654    }
655    Ok((body, length))
656}
657
658/// Serves one request from one app artifact root without invoking a shell or C runtime.
659///
660/// # Parameters
661///
662/// - `root`: Input consumed by [`crate::artifact_server::serve_artifact_request`].
663/// - `method`: Input consumed by [`crate::artifact_server::serve_artifact_request`].
664/// - `target`: Input consumed by [`crate::artifact_server::serve_artifact_request`].
665///
666/// # Returns
667///
668/// Returns the computed value produced by [`crate::artifact_server::serve_artifact_request`].
669///
670/// # Examples
671///
672/// ```rust,ignore
673/// let _result = pharos_runtime_static::artifact_server::serve_artifact_request(std::path::Path::new("."), todo!(), "/");
674/// ```
675///
676/// # Related
677///
678/// - [`crate::artifact_server`] for adjacent helpers that share the same runtime surface.
679pub fn serve_artifact_request(root: &Path, method: &str, target: &str) -> StaticResponse {
680    if !matches!(method, "GET" | "HEAD") {
681        return text_error(405, "Method Not Allowed", "method not allowed\n");
682    }
683    if root.as_os_str().is_empty() || !root.is_dir() {
684        return text_error(500, "Internal Server Error", "artifact root unavailable\n");
685    }
686    let normalized = match normalized_path(target) {
687        Some(path) => path,
688        None => return text_error(400, "Bad Request", "invalid request path\n"),
689    };
690    let asset = resolve_direct_asset(root, &normalized).ok().flatten();
691    let route = if asset.is_none() {
692        lookup_route_map(root, &normalized).ok().flatten()
693    } else {
694        None
695    };
696    let Some((file, content_type)) = asset.or(route) else {
697        return text_error(404, "Not Found", "not found\n");
698    };
699    let (body, content_length) = match read_file(&file) {
700        Ok(value) => value,
701        Err(_) => return text_error(404, "Not Found", "not found\n"),
702    };
703    let body = if method == "HEAD" { Vec::new() } else { body };
704    response(200, "OK", content_type, body, content_length)
705}
706
707/// Performs response bytes.
708fn response_bytes(response: StaticResponse, method: &str) -> Vec<u8> {
709    let mut headers = format!(
710        "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
711        response.status_code, response.status_text, response.content_type, response.content_length,
712    )
713    .into_bytes();
714    if method != "HEAD" {
715        headers.extend_from_slice(&response.body);
716    }
717    headers
718}
719
720/// Handles request head.
721fn request_head(
722    stream: &mut std::net::TcpStream,
723) -> Result<(String, String, Option<String>, Option<String>), ()> {
724    let mut bytes = Vec::with_capacity(1024);
725    let mut chunk = [0u8; 1024];
726    loop {
727        let read = stream.read(&mut chunk).map_err(|_| ())?;
728        if read == 0 {
729            return Err(());
730        }
731        bytes.extend_from_slice(&chunk[..read]);
732        if bytes.windows(4).any(|window| window == b"\r\n\r\n")
733            || bytes.windows(2).any(|window| window == b"\n\n")
734        {
735            let text = std::str::from_utf8(&bytes).map_err(|_| ())?;
736            let mut lines = text.lines();
737            let mut fields = lines.next().ok_or(())?.split_whitespace();
738            let method = fields.next().ok_or(())?.to_owned();
739            let target = fields.next().ok_or(())?.to_owned();
740            if !matches!(fields.next(), Some(version) if version.starts_with("HTTP/")) {
741                return Err(());
742            }
743            let mut range = None;
744            let mut if_range = None;
745            for line in lines.take(256) {
746                let Some((name, value)) = line.split_once(':') else {
747                    continue;
748                };
749                match name.trim().to_ascii_lowercase().as_str() {
750                    "range" if range.is_none() => range = Some(value.trim().to_owned()),
751                    "if-range" if if_range.is_none() => if_range = Some(value.trim().to_owned()),
752                    _ => {}
753                }
754            }
755            return Ok((method, target, range, if_range));
756        }
757        if bytes.len() > 16 * 1024 {
758            return Err(());
759        }
760    }
761}
762
763/// Sends all.
764fn send_all(stream: &mut std::net::TcpStream, bytes: &[u8]) -> Result<(), ()> {
765    stream.write_all(bytes).map_err(|_| ())
766}
767
768/// Streams one selected response body to the standalone static server client.
769fn send_stream(
770    stream: &mut std::net::TcpStream,
771    response: &StreamResponse,
772    method: &str,
773) -> Result<(), ()> {
774    let content_range = if response.status_code == 206 {
775        format!(
776            "Content-Range: bytes {}-{}/{}\r\n",
777            response.offset,
778            response.offset + response.content_length.saturating_sub(1),
779            response.complete_length
780        )
781    } else {
782        String::new()
783    };
784    let headers = format!(
785        "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\nETag: {}\r\nCache-Control: {}\r\nX-Content-Type-Options: nosniff\r\n{}{}Connection: close\r\n\r\n",
786        response.status_code,
787        response.status_text,
788        response.content_type,
789        response.content_length,
790        response.etag,
791        response.cache_control,
792        content_range,
793        response
794            .content_disposition
795            .as_ref()
796            .map(|value| format!("Content-Disposition: {value}\r\n"))
797            .unwrap_or_default(),
798    );
799    send_all(stream, headers.as_bytes())?;
800    if method == "HEAD" {
801        return Ok(());
802    }
803    let mut file = File::open(&response.file_path).map_err(|_| ())?;
804    file.seek(SeekFrom::Start(response.offset))
805        .map_err(|_| ())?;
806    let mut remaining = response.content_length;
807    let mut buffer = [0u8; 64 * 1024];
808    while remaining > 0 {
809        let count = usize::try_from(remaining.min(buffer.len() as u64)).map_err(|_| ())?;
810        let read = file.read(&mut buffer[..count]).map_err(|_| ())?;
811        if read == 0 {
812            return Err(());
813        }
814        send_all(stream, &buffer[..read])?;
815        remaining = remaining.saturating_sub(read as u64);
816    }
817    Ok(())
818}
819
820/// Handles client.
821fn handle_client(stream: &mut std::net::TcpStream, root: &Path) -> Result<(), ()> {
822    let (method, target, range, if_range) = match request_head(stream) {
823        Ok(request) => request,
824        Err(_) => {
825            let bytes = response_bytes(text_error(400, "Bad Request", "bad request\n"), "GET");
826            return send_all(stream, &bytes);
827        }
828    };
829    match serve_streamable_asset(
830        root,
831        &method,
832        &target,
833        range.as_deref(),
834        if_range.as_deref(),
835    ) {
836        Ok(Some(response)) => return send_stream(stream, &response, &method),
837        Err(error) => {
838            let mut bytes = response_bytes(error.response, &method);
839            if let Some(length) = error.unsatisfied_length
840                && let Some(separator) = bytes.windows(2).position(|window| window == b"\r\n")
841            {
842                let header = format!("Content-Range: bytes */{length}\r\nAccept-Ranges: bytes\r\n");
843                bytes.splice(separator + 2..separator + 2, header.bytes());
844            }
845            return send_all(stream, &bytes);
846        }
847        Ok(None) => {}
848    }
849    let response = serve_artifact_request(root, &method, &target);
850    let bytes = response_bytes(response, &method);
851    send_all(stream, &bytes)
852}
853
854/// Runs the bounded loopback static-server binary for one packaged artifact.
855///
856/// # Parameters
857///
858/// - `root`: Input consumed by [`crate::artifact_server::run`].
859/// - `port`: Input consumed by [`crate::artifact_server::run`].
860/// - `max_requests`: Input consumed by [`crate::artifact_server::run`].
861///
862/// # Returns
863///
864/// Returns the computed value produced by [`crate::artifact_server::run`].
865///
866/// # Performance
867///
868/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::artifact_server::run`].
869///
870/// # Examples
871///
872/// ```rust,ignore
873/// let _result = pharos_runtime_static::artifact_server::run(std::path::Path::new("."), "8080", todo!());
874/// ```
875///
876/// # Related
877///
878/// - [`crate::artifact_server`] for adjacent helpers that share the same runtime surface.
879pub fn run(root: &Path, port: u16, max_requests: usize) -> i32 {
880    if root.as_os_str().is_empty() || !root.is_dir() || port == 0 || max_requests == 0 {
881        return 64;
882    }
883    let listener = match std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) {
884        Ok(listener) => listener,
885        Err(_) => return 70,
886    };
887    for _ in 0..max_requests {
888        let (mut stream, _) = match listener.accept() {
889            Ok(connection) => connection,
890            Err(_) => return 71,
891        };
892        if handle_client(&mut stream, root).is_err() {
893            return 72;
894        }
895    }
896    0
897}
898
899#[cfg(test)]
900mod tests {
901    use super::{lookup_route_map, serve_private_asset, serve_streamable_asset};
902
903    /// Serves configured external media while retaining bundled assets and rejecting symlink escapes.
904    #[test]
905    fn configured_media_root_separates_media_from_models() {
906        let root = root("configured-media");
907        let app = root.join("app");
908        let media = root.join("uploads");
909        let models = root.join("models");
910        std::fs::create_dir_all(app.join("media")).unwrap();
911        std::fs::create_dir_all(&media).unwrap();
912        std::fs::create_dir_all(&models).unwrap();
913        std::fs::write(
914            app.join("app.conf"),
915            format!("MEDIA_ROOT={}\n", media.display()),
916        )
917        .unwrap();
918        std::fs::write(media.join("uploaded.png"), b"upload").unwrap();
919        std::fs::write(app.join("media/bundled.png"), b"bundled").unwrap();
920        std::fs::write(models.join("private.bin"), b"model").unwrap();
921        assert_eq!(
922            super::resolve_direct_asset(&app, "/media/uploaded.png")
923                .unwrap()
924                .unwrap()
925                .0,
926            media.join("uploaded.png").canonicalize().unwrap()
927        );
928        assert!(
929            super::resolve_direct_asset(&app, "/media/bundled.png")
930                .unwrap()
931                .is_some()
932        );
933        assert!(
934            super::resolve_direct_asset(&app, "/media/private.bin")
935                .ok()
936                .flatten()
937                .is_none()
938        );
939        assert!(super::resolve_direct_asset(&app, "/media/../models/private.bin").is_err());
940        #[cfg(unix)]
941        {
942            std::os::unix::fs::symlink(models.join("private.bin"), media.join("escape.bin"))
943                .unwrap();
944            assert!(
945                super::resolve_direct_asset(&app, "/media/escape.bin")
946                    .ok()
947                    .flatten()
948                    .is_none()
949            );
950        }
951        std::fs::create_dir_all(media.join("attachments")).unwrap();
952        std::fs::write(media.join("attachments/private.bin"), b"private context").unwrap();
953        assert!(super::resolve_direct_asset(&app, "/media/attachments/private.bin").is_err());
954        std::fs::remove_dir_all(root).unwrap();
955    }
956
957    /// Creates one unique temporary artifact root.
958    fn root(label: &str) -> std::path::PathBuf {
959        let nonce = std::time::SystemTime::now()
960            .duration_since(std::time::UNIX_EPOCH)
961            .expect("clock")
962            .as_nanos();
963        std::env::temp_dir().join(format!(
964            "pharos-static-{label}-{}-{nonce}",
965            std::process::id()
966        ))
967    }
968
969    #[test]
970    /// Performs audio ranges select exact file slices.
971    fn audio_ranges_select_exact_file_slices() {
972        let root = root("range");
973        let media = root.join("media");
974        std::fs::create_dir_all(&media).expect("media");
975        std::fs::write(media.join("preview-a1b2c3d4.mp3"), b"0123456789").expect("asset");
976        let partial = serve_streamable_asset(
977            &root,
978            "GET",
979            "/media/preview-a1b2c3d4.mp3",
980            Some("bytes=2-5"),
981            None,
982        )
983        .expect("selection")
984        .expect("asset");
985        assert_eq!(partial.status_code, 206);
986        assert_eq!(partial.offset, 2);
987        assert_eq!(partial.content_length, 4);
988        assert_eq!(partial.complete_length, 10);
989        assert_eq!(partial.content_type, "audio/mpeg");
990        assert!(partial.cache_control.contains("immutable"));
991
992        let suffix = serve_streamable_asset(
993            &root,
994            "GET",
995            "/media/preview-a1b2c3d4.mp3",
996            Some("bytes=-3"),
997            None,
998        )
999        .expect("selection")
1000        .expect("asset");
1001        assert_eq!((suffix.offset, suffix.content_length), (7, 3));
1002        std::fs::remove_dir_all(root).expect("cleanup");
1003    }
1004
1005    #[test]
1006    /// Performs invalid ranges fail and if range mismatch returns complete body.
1007    fn invalid_ranges_fail_and_if_range_mismatch_returns_complete_body() {
1008        let root = root("if-range");
1009        let media = root.join("media");
1010        std::fs::create_dir_all(&media).expect("media");
1011        std::fs::write(media.join("preview.ogg"), b"0123456789").expect("asset");
1012        let error = serve_streamable_asset(
1013            &root,
1014            "GET",
1015            "/media/preview.ogg",
1016            Some("bytes=40-50"),
1017            None,
1018        )
1019        .expect_err("unsatisfied");
1020        assert_eq!(error.response.status_code, 416);
1021        assert_eq!(error.unsatisfied_length, Some(10));
1022
1023        let complete = serve_streamable_asset(
1024            &root,
1025            "GET",
1026            "/media/preview.ogg",
1027            Some("bytes=2-5"),
1028            Some("\"stale-validator\""),
1029        )
1030        .expect("selection")
1031        .expect("asset");
1032        assert_eq!(complete.status_code, 200);
1033        assert_eq!((complete.offset, complete.content_length), (0, 10));
1034        std::fs::remove_dir_all(root).expect("cleanup");
1035    }
1036
1037    #[test]
1038    /// Performs private assets are no store attachments and cannot escape.
1039    fn private_assets_are_no_store_attachments_and_cannot_escape() {
1040        let root = root("private");
1041        let private = root.join("private/downloads");
1042        std::fs::create_dir_all(&private).expect("private");
1043        std::fs::write(private.join("pack.zip"), b"zip-body").expect("asset");
1044        let response = serve_private_asset(
1045            &root,
1046            "private/downloads",
1047            "pack.zip",
1048            "GET",
1049            Some("bytes=0-2"),
1050            None,
1051        )
1052        .expect("private selection");
1053        assert_eq!(response.status_code, 206);
1054        assert_eq!(response.content_type, "application/zip");
1055        assert_eq!(response.cache_control, "private, no-store");
1056        assert_eq!(
1057            response.content_disposition.as_deref(),
1058            Some("attachment; filename=\"pack.zip\"")
1059        );
1060        assert!(
1061            serve_private_asset(&root, "private/downloads", "../pack.zip", "GET", None, None,)
1062                .is_err()
1063        );
1064        std::fs::remove_dir_all(root).expect("cleanup");
1065    }
1066
1067    #[test]
1068    /// Matches declared directory routes using the same normalization as requests.
1069    fn route_map_lookup_normalizes_declared_directory_paths() {
1070        let root = root("directory-route-map");
1071        std::fs::create_dir_all(&root).expect("artifact root");
1072        std::fs::write(root.join("about.html"), "about").expect("artifact");
1073        std::fs::write(
1074            root.join("runtime-route-map.tsv"),
1075            "GET\t/about/\tabout.html\ttext/html; charset=utf-8\t200\n",
1076        )
1077        .expect("route map");
1078        for path in ["/about/", "/about", "/about/?review=1"] {
1079            assert!(lookup_route_map(&root, path).expect("lookup").is_some());
1080        }
1081        assert!(lookup_route_map(&root, "/../about/").is_err());
1082        std::fs::remove_dir_all(root).expect("cleanup");
1083    }
1084
1085    #[test]
1086    /// Resolves route-map entries even when the matching path appears after many earlier rows.
1087    fn route_map_lookup_scans_beyond_four_thousand_entries() {
1088        let root = root("route-map");
1089        let pages = root.join("reference");
1090        std::fs::create_dir_all(&pages).expect("reference dir");
1091        std::fs::write(
1092            pages.join("library-reference.html"),
1093            "<html><body>library</body></html>",
1094        )
1095        .expect("artifact");
1096        let mut route_map = String::new();
1097        for index in 0..5000 {
1098            route_map.push_str(&format!(
1099                "GET\t/filler/{index}.html\tfiller/{index}.html\ttext/html; charset=utf-8\t200\n"
1100            ));
1101        }
1102        route_map.push_str(
1103            "GET\t/reference/library-reference.html\treference/library-reference.html\ttext/html; charset=utf-8\t200\n",
1104        );
1105        std::fs::write(root.join("runtime-route-map.tsv"), route_map).expect("route map");
1106        let resolved = lookup_route_map(&root, "/reference/library-reference.html")
1107            .expect("lookup")
1108            .expect("resolved");
1109        assert_eq!(resolved.1, "text/html; charset=utf-8");
1110        assert_eq!(
1111            resolved.0.file_name().and_then(|name| name.to_str()),
1112            Some("library-reference.html")
1113        );
1114        std::fs::remove_dir_all(root).expect("cleanup");
1115    }
1116    /// Keeps direct preview font responses consistent with packaged route-map font declarations.
1117    #[test]
1118    fn direct_font_assets_use_font_media_types() {
1119        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1120            .join("../../..")
1121            .join("tmp/0.8.4-testing")
1122            .join(format!("pharos-font-assets-{}", std::process::id()));
1123        std::fs::create_dir_all(root.join("static/fonts")).unwrap();
1124        let bytes = b"wOF2 local font test input";
1125        std::fs::write(root.join("static/fonts/icons.woff2"), bytes).unwrap();
1126        let response = super::serve_artifact_request(&root, "GET", "/static/fonts/icons.woff2");
1127        assert_eq!(response.status_code, 200);
1128        assert_eq!(response.content_type, "font/woff2");
1129        assert_eq!(response.body, bytes);
1130        std::fs::remove_dir_all(root).unwrap();
1131    }
1132}