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)]
12pub struct StreamResponse {
37 pub status_code: u16,
39 pub status_text: &'static str,
41 pub content_type: &'static str,
43 pub file_path: PathBuf,
45 pub offset: u64,
47 pub content_length: u64,
49 pub complete_length: u64,
51 pub etag: String,
53 pub cache_control: &'static str,
55 pub content_disposition: Option<String>,
57}
58
59#[derive(Debug, Clone, Eq, PartialEq)]
60pub struct StreamError {
77 pub response: StaticResponse,
79 pub unsatisfied_length: Option<u64>,
81}
82
83fn stream_error(response: StaticResponse) -> StreamError {
85 StreamError {
86 response,
87 unsatisfied_length: None,
88 }
89}
90
91#[derive(Debug, Clone, Eq, PartialEq)]
92pub struct StaticResponse {
112 pub status_code: u16,
114 pub status_text: &'static str,
116 pub content_type: &'static str,
118 pub body: Vec<u8>,
120 pub content_length: u64,
122}
123
124fn 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
141fn 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
158fn 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
186pub fn path_has_parent_reference(path: &str) -> bool {
210 path.split(['/', '\\']).any(|segment| segment == "..")
211}
212
213fn 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
249fn 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
277fn 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
305fn 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
316fn 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
332pub 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
418pub 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
512fn safe_artifact_path(root: &Path, relative: &str) -> Option<PathBuf> {
514 pharos_runtime_core::safe_existing_artifact_path(root, relative)
515}
516
517pub 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 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
572pub 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
640fn 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
658pub 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
707fn 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
720fn 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
763fn send_all(stream: &mut std::net::TcpStream, bytes: &[u8]) -> Result<(), ()> {
765 stream.write_all(bytes).map_err(|_| ())
766}
767
768fn 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
820fn 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
854pub 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 #[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 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 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 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 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 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 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 #[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}