Skip to main content

sz_rust_core/
static_files.rs

1//! 静态文件服务 — `tower-http::services::ServeDir` 封装 + 自定义文件处理器
2//!
3//! 提供静态文件服务,用于托管前端 SPA、图片、CSS、JS 等静态资源。
4//!
5//! ## 功能
6//!
7//! - 目录映射:将 URL 前缀映射到文件系统目录
8//! - 默认首页:访问目录时自动返回 `index.html`
9//! - SPA fallback:未匹配的路径回退到 `index.html`(用于前端路由)
10//! - 安全防护:自动阻止路径穿越(`../`)
11//! - 自定义文件处理器(对齐 PHP think-worker `sendFile`)
12//! - MIME 类型识别(对齐 PHP `$mimeTypeMap` + `finfo` 后备)
13//! - Range 请求支持(对齐 Webman `sendFile` 206 Partial Content)
14//! - 304 Not Modified(对齐 PHP `If-Modified-Since` 检查)
15//! - Last-Modified 头(对齐 PHP `filemtime`)
16//!
17//! ## 用法
18//!
19//! ```ignore
20//! use sz_rust_core::static_files::{static_router, static_router_spa, serve_file};
21//! use axum::Router;
22//!
23//! // 普通静态目录(基于 ServeDir)
24//! let app: Router = Router::new()
25//!     .merge(static_router("/static", "./public"));
26//!
27//! // SPA 应用(fallback 到 index.html)
28//! let app: Router = Router::new()
29//!     .merge(static_router_spa("./dist"));
30//!
31//! // 自定义文件处理器(对齐 PHP think-worker sendFile)
32//! use axum::http::HeaderMap;
33//! async fn handler(headers: HeaderMap) -> axum::response::Response {
34//!     serve_file(std::path::Path::new("./public/style.css"), &headers)
35//! }
36//! ```
37//!
38//! ## PHP 对齐
39//!
40//! | PHP 方法 | 行为 | Rust 等价 |
41//! |---------|------|-----------|
42//! | `think-worker\Http::sendFile()` | 304/Last-Modified/Content-Type/Content-Length | [`serve_file`] |
43//! | `think-worker\Http::getMimeType()` | 扩展名表 + `finfo` 后备 | [`mime_type_for_path`] |
44//! | `WebServer::sendFile()` Range 分支 | 206 Partial Content + Content-Range | [`serve_file`] Range 分支 |
45//! | `$mimeTypeMap` | 扩展名 → MIME 映射表 | [`mime_type_for_extension`] |
46
47use axum::Router;
48use std::path::{Path, PathBuf};
49use tower_http::services::{ServeDir, ServeFile};
50
51// ============================================================================
52// 基于 ServeDir 的封装(向后兼容)
53// ============================================================================
54
55/// 创建静态文件 `ServeDir`(用于 `nest_service`)
56///
57/// - 访问 `/static/foo.css` → 返回 `./public/foo.css`
58/// - 文件不存在 → 返回 404
59pub fn static_dir(path: impl AsRef<Path>) -> ServeDir {
60    ServeDir::new(path)
61}
62
63/// 创建带默认 `index.html` 的 `ServeDir`
64///
65/// 当访问目录(如 `/static/`)时返回 `index.html`。
66pub fn static_dir_with_index(path: impl AsRef<Path>) -> ServeDir {
67    ServeDir::new(path).append_index_html_on_directories(true)
68}
69
70/// 创建 SPA `ServeDir`(fallback 到 `index.html`)
71///
72/// 所有未匹配的路径都返回 `index.html`,用于前端路由(如 React Router / Vue Router)。
73pub fn static_dir_spa(path: impl AsRef<Path>) -> ServeDir<ServeFile> {
74    let index = path.as_ref().join("index.html");
75    ServeDir::new(path).fallback(ServeFile::new(index))
76}
77
78/// 创建静态文件 Router 并挂载到指定路径
79///
80/// 等价于 `Router::new().nest_service(prefix, ServeDir::new(path))`。
81pub fn static_router(prefix: &str, path: impl AsRef<Path>) -> Router {
82    Router::new().nest_service(prefix, ServeDir::new(path))
83}
84
85/// 创建带默认 `index.html` 的静态文件 Router
86pub fn static_router_with_index(prefix: &str, path: impl AsRef<Path>) -> Router {
87    Router::new().nest_service(prefix, static_dir_with_index(path))
88}
89
90/// 创建 SPA 静态文件 Router 并作为 fallback
91///
92/// 等价于 `Router::new().fallback_service(ServeDir::new(path).fallback(ServeFile::new(index)))`。
93pub fn static_router_spa(path: impl AsRef<Path>) -> Router {
94    Router::new().fallback_service(static_dir_spa(path))
95}
96
97/// 创建单文件服务(如 favicon.ico)
98pub fn static_file(path: impl AsRef<Path>) -> ServeFile {
99    ServeFile::new(path)
100}
101
102// ============================================================================
103// 自定义文件处理器(对齐 PHP think-worker sendFile + Webman Range)
104// ============================================================================
105
106/// MIME 类型表(对齐 PHP think-worker `$mimeTypeMap`)
107///
108/// PHP `$mimeTypeMap` 从 `mime.types` 文件加载,此处硬编码常见类型。
109/// 对齐 PHP `think-worker\Http::$mimeTypeMap` + Webman `WebServer::$mimeTypeMap`。
110const MIME_TYPES: &[(&str, &str)] = &[
111    // 文本
112    ("html", "text/html"),
113    ("htm", "text/html"),
114    ("shtml", "text/html"),
115    ("css", "text/css"),
116    ("xml", "text/xml"),
117    ("txt", "text/plain"),
118    ("md", "text/markdown"),
119    ("csv", "text/csv"),
120    // JavaScript
121    ("js", "application/javascript"),
122    ("mjs", "application/javascript"),
123    ("json", "application/json"),
124    // 图片
125    ("png", "image/png"),
126    ("jpg", "image/jpeg"),
127    ("jpeg", "image/jpeg"),
128    ("gif", "image/gif"),
129    ("bmp", "image/bmp"),
130    ("ico", "image/x-icon"),
131    ("svg", "image/svg+xml"),
132    ("webp", "image/webp"),
133    ("avif", "image/avif"),
134    // 音视频
135    ("mp3", "audio/mpeg"),
136    ("wav", "audio/wav"),
137    ("ogg", "audio/ogg"),
138    ("mp4", "video/mp4"),
139    ("webm", "video/webm"),
140    ("m3u8", "application/vnd.apple.mpegurl"),
141    ("ts", "video/mp2t"),
142    // 字体
143    ("woff", "font/woff"),
144    ("woff2", "font/woff2"),
145    ("ttf", "font/ttf"),
146    ("otf", "font/otf"),
147    ("eot", "application/vnd.ms-fontobject"),
148    // 文档
149    ("pdf", "application/pdf"),
150    ("zip", "application/zip"),
151    ("gz", "application/gzip"),
152    ("tar", "application/x-tar"),
153    ("rar", "application/vnd.rar"),
154    ("7z", "application/x-7z-compressed"),
155    // WebAssembly
156    ("wasm", "application/wasm"),
157    // 其他
158    ("swf", "application/x-shockwave-flash"),
159    ("doc", "application/msword"),
160    (
161        "docx",
162        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
163    ),
164    ("xls", "application/vnd.ms-excel"),
165    (
166        "xlsx",
167        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
168    ),
169    ("ppt", "application/vnd.ms-powerpoint"),
170    (
171        "pptx",
172        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
173    ),
174];
175
176/// 根据扩展名获取 MIME 类型(对齐 PHP `$mimeTypeMap[$extension]`)
177///
178/// 扩展名不区分大小写。返回 `None` 表示未找到(对齐 PHP 走 `finfo` 后备)。
179pub fn mime_type_for_extension(ext: &str) -> Option<&'static str> {
180    let ext_lower = ext.to_lowercase();
181    MIME_TYPES
182        .iter()
183        .find(|(k, _)| *k == ext_lower)
184        .map(|(_, v)| *v)
185}
186
187/// 根据文件路径获取 MIME 类型(对齐 PHP `think-worker\Http::getMimeType`)
188///
189/// PHP 逻辑:先查 `$mimeTypeMap[extension]`,未找到则用 `finfo_file()`。
190/// Rust 逻辑:先查硬编码表,未找到则用 `mime_guess::from_path()`。
191pub fn mime_type_for_path(path: &Path) -> Option<String> {
192    // 1. 先查扩展名表(对齐 PHP $mimeTypeMap)
193    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
194        if let Some(mime) = mime_type_for_extension(ext) {
195            return Some(mime.to_string());
196        }
197    }
198    // 2. 后备:mime_guess(对齐 PHP finfo_file)
199    mime_guess::from_path(path).first().map(|m| m.to_string())
200}
201
202/// HTTP Range 解析结果(对齐 Webman `sendFile` Range 分支)
203#[derive(Debug, Clone, PartialEq)]
204pub struct RangeSpec {
205    /// 起始字节偏移(含)
206    pub start: u64,
207    /// 结束字节偏移(含)
208    pub end: u64,
209}
210
211/// Range 解析错误
212#[derive(Debug, Clone, PartialEq)]
213pub enum RangeError {
214    /// 非法格式(非 `bytes=` 前缀)
215    InvalidFormat,
216    /// 非法范围(start > end 或 start >= file_size)
217    InvalidRange,
218    /// 不可满足的范围(超出文件大小)
219    Unsatisfiable,
220}
221
222/// 解析 Range 头(对齐 Webman `sendFile` 中 `explode('=', ..., 2)` + `explode('-', ...)`)
223///
224/// 支持三种格式(对齐 HTTP/1.1 Range 规范):
225/// - `bytes=0-499` → RangeSpec { start: 0, end: 499 }
226/// - `bytes=500-` → RangeSpec { start: 500, end: file_size - 1 }
227/// - `bytes=-500` → 最后 500 字节,RangeSpec { start: file_size-500, end: file_size-1 }
228///
229/// PHP Webman 仅支持 `bytes=start-end` 和 `bytes=start-`,不支持 `bytes=-suffix`。
230/// Rust 实现完整支持三种格式,超出 PHP 对齐范围但符合 HTTP 规范。
231pub fn parse_range_header(range: &str, file_size: u64) -> Result<RangeSpec, RangeError> {
232    // 对齐 PHP: list(, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2)
233    let range = range.trim();
234    let range_value = range
235        .strip_prefix("bytes=")
236        .ok_or(RangeError::InvalidFormat)?;
237
238    // 对齐 PHP: list($start, $end) = explode('-', $range)
239    let (start_str, end_str) = range_value
240        .split_once('-')
241        .ok_or(RangeError::InvalidFormat)?;
242
243    let (start, end) = match (start_str.is_empty(), end_str.is_empty()) {
244        // bytes=-suffix(最后 N 字节)
245        (true, false) => {
246            let suffix: u64 = end_str.parse().map_err(|_| RangeError::InvalidRange)?;
247            if suffix == 0 {
248                return Err(RangeError::InvalidRange);
249            }
250            let start = file_size.saturating_sub(suffix);
251            (start, file_size.saturating_sub(1))
252        }
253        // bytes=start-(从 start 到文件末尾)
254        (false, true) => {
255            let start: u64 = start_str.parse().map_err(|_| RangeError::InvalidRange)?;
256            if start >= file_size {
257                return Err(RangeError::Unsatisfiable);
258            }
259            (start, file_size.saturating_sub(1))
260        }
261        // bytes=start-end
262        (false, false) => {
263            let start: u64 = start_str.parse().map_err(|_| RangeError::InvalidRange)?;
264            let end: u64 = end_str.parse().map_err(|_| RangeError::InvalidRange)?;
265            if start > end {
266                return Err(RangeError::InvalidRange);
267            }
268            if start >= file_size {
269                return Err(RangeError::Unsatisfiable);
270            }
271            // end 超出文件大小时截断(对齐 PHP: $end = is_numeric($end) ? $end : $file_size - 1)
272            let end = end.min(file_size.saturating_sub(1));
273            (start, end)
274        }
275        // bytes=- (空范围)
276        (true, true) => return Err(RangeError::InvalidRange),
277    };
278
279    Ok(RangeSpec { start, end })
280}
281
282/// 路径安全验证(防止 `../` 路径穿越)
283///
284/// 检查规范化后的路径是否仍在根目录内。
285/// 对齐 nginx/Apache 的路径穿越防护,PHP `realpath()` 检查。
286pub fn is_path_safe(path: &Path, root: &Path) -> bool {
287    // 规范化路径(解析 `.` 和 `..`)
288    let canonical_root = match root.canonicalize() {
289        Ok(p) => p,
290        Err(_) => return false,
291    };
292    let canonical_path = match path.canonicalize() {
293        Ok(p) => p,
294        Err(_) => return false,
295    };
296    // 检查规范化后的路径是否以根目录为前缀
297    canonical_path.starts_with(&canonical_root)
298}
299
300/// 检查路径是否包含 `..` 组件(路径遍历特征)
301///
302/// P1-PATH-01 防御性检查:即使调用方遗漏 `is_path_safe` 校验,
303/// `serve_file` / `serve_file_with_cache` 自身也拒绝包含父目录跳转的路径。
304fn has_traversal_component(path: &Path) -> bool {
305    use std::path::Component;
306    path.components().any(|c| matches!(c, Component::ParentDir))
307}
308
309/// 格式化 HTTP 日期(对齐 PHP `date('D, d M Y H:i:s', $time) . ' GMT'`)
310///
311/// PHP 使用服务器时区,但 Last-Modified 头必须用 GMT。
312/// Rust 直接格式化为 GMT。
313fn format_http_date(timestamp: std::time::SystemTime) -> String {
314    use std::time::UNIX_EPOCH;
315    let secs = timestamp
316        .duration_since(UNIX_EPOCH)
317        .map(|d| d.as_secs())
318        .unwrap_or(0);
319
320    // 简化的日期格式化(对齐 PHP 'D, d M Y H:i:s' + ' GMT')
321    // 不依赖 chrono,手动计算
322    let (year, month, day, hour, minute, second, weekday) = secs_to_date_time(secs);
323
324    let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
325    let months = [
326        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
327    ];
328
329    format!(
330        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
331        weekdays[weekday as usize],
332        day,
333        months[(month - 1) as usize],
334        year,
335        hour,
336        minute,
337        second,
338    )
339}
340
341/// Unix 时间戳 → (年, 月, 日, 时, 分, 秒, 星期几)
342///
343/// 基于 Howard Hinnant 的日期算法(civil_from_days)。
344/// 参考: https://howardhinnant.github.io/date_algorithms.html
345fn secs_to_date_time(secs: u64) -> (u64, u64, u64, u64, u64, u64, u64) {
346    let secs_in_day = 86400u64;
347    let mut days = secs / secs_in_day;
348    let remainder = secs % secs_in_day;
349
350    let hour = remainder / 3600;
351    let minute = (remainder % 3600) / 60;
352    let second = remainder % 60;
353
354    // 1970-01-01 是星期四
355    let weekday = (days + 4) % 7;
356
357    // Howard Hinnant civil_from_days 算法
358    days += 719468; // 从 0000-03-01 开始
359    let era = days / 146097;
360    let doe = days - era * 146097; // [0, 146096]
361                                   // 注意: 常量是 1460/36524/146096(不含闰日),不是 1461/36524/146097
362    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
363    let y = yoe + era * 400;
364    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
365    let mp = (5 * doy + 2) / 153; // [0, 11]
366    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
367    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
368    let year = if m <= 2 { y + 1 } else { y };
369
370    (year, m, d, hour, minute, second, weekday)
371}
372
373/// 自定义文件处理器(对齐 PHP think-worker `sendFile` + Webman Range)
374///
375/// 完整对齐 PHP `think-worker\Http::sendFile()`:
376/// 1. 304 检查(`If-Modified-Since` 匹配 → 304 Not Modified)
377/// 2. MIME 类型识别(扩展名表 + `mime_guess` 后备)
378/// 3. Content-Type(已知 MIME → 直接设置,未知 → `application/octet-stream` + `Content-Disposition`)
379/// 4. Last-Modified 头
380/// 5. Content-Length 头
381///
382/// 额外对齐 Webman `sendFile()`:
383/// 6. Range 请求(`Range: bytes=start-end` → 206 Partial Content + Content-Range)
384/// 7. Accept-Ranges: bytes
385///
386/// # 参数
387/// - `path` — 文件路径
388/// - `headers` — 请求头(用于 `If-Modified-Since` 和 `Range`)
389///
390/// # 返回
391/// - 200 OK — 完整文件
392/// - 206 Partial Content — Range 请求
393/// - 304 Not Modified — `If-Modified-Since` 匹配
394/// - 404 Not Found — 文件不存在
395/// - 416 Range Not Satisfiable — Range 超出文件大小
396pub async fn serve_file(path: &Path, headers: &axum::http::HeaderMap) -> axum::response::Response {
397    use axum::body::Body;
398    use axum::http::{header, StatusCode};
399    use axum::response::IntoResponse;
400
401    // P1-PATH-01: 防御性路径遍历检查
402    // 即使调用方遗漏 is_path_safe 校验,也拒绝包含 .. 的路径
403    if has_traversal_component(path) {
404        return (StatusCode::NOT_FOUND, "Not found").into_response();
405    }
406
407    // 1. 检查文件存在
408    if !path.is_file() {
409        return (StatusCode::NOT_FOUND, "File not found").into_response();
410    }
411
412    // 2. 读取文件元数据(P1-IO-02:使用 tokio::fs 避免阻塞 async 运行时)
413    let metadata = match tokio::fs::metadata(path).await {
414        Ok(m) => m,
415        Err(_) => {
416            return (
417                StatusCode::INTERNAL_SERVER_ERROR,
418                "Failed to read file metadata",
419            )
420                .into_response()
421        }
422    };
423    let file_size = metadata.len();
424    let modified = metadata.modified().ok();
425
426    // 3. 304 检查(对齐 PHP If-Modified-Since)
427    if let Some(modified_time) = modified {
428        let last_modified = format_http_date(modified_time);
429        if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE) {
430            if let Ok(ims_str) = if_modified_since.to_str() {
431                if ims_str.trim() == last_modified {
432                    return (
433                        StatusCode::NOT_MODIFIED,
434                        [(header::LAST_MODIFIED, last_modified.as_str())],
435                        Body::empty(),
436                    )
437                        .into_response();
438                }
439            }
440        }
441    }
442
443    // 4. MIME 类型识别(对齐 PHP getMimeType)
444    let mime = mime_type_for_path(path);
445    let content_type = mime
446        .clone()
447        .unwrap_or_else(|| "application/octet-stream".to_string());
448
449    // 5. 读取文件内容(P1-IO-02:使用 tokio::fs 避免阻塞 async 运行时)
450    let content = match tokio::fs::read(path).await {
451        Ok(c) => c,
452        Err(_) => {
453            return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
454        }
455    };
456
457    // 6. Range 请求处理(对齐 Webman Range 分支)
458    if let Some(range_header) = headers.get(header::RANGE) {
459        if let Ok(range_str) = range_header.to_str() {
460            match parse_range_header(range_str, file_size) {
461                Ok(range) => {
462                    let content_length = range.end - range.start + 1;
463                    let bytes = content
464                        .get(range.start as usize..=(range.end as usize))
465                        .unwrap_or(&[]);
466                    let content_range =
467                        format!("bytes {}-{}/{}", range.start, range.end, file_size);
468                    let content_length_str = content_length.to_string();
469
470                    let mut response = (
471                        StatusCode::PARTIAL_CONTENT,
472                        [
473                            (header::CONTENT_TYPE, content_type.as_str()),
474                            (header::CONTENT_LENGTH, content_length_str.as_str()),
475                            (header::CONTENT_RANGE, content_range.as_str()),
476                            (header::ACCEPT_RANGES, "bytes"),
477                        ],
478                        Body::from(bytes.to_vec()),
479                    )
480                        .into_response();
481
482                    if let Some(modified_time) = modified {
483                        let last_modified = format_http_date(modified_time);
484                        if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
485                            response.headers_mut().insert(header::LAST_MODIFIED, val);
486                        }
487                    }
488                    return response;
489                }
490                Err(RangeError::Unsatisfiable) => {
491                    let content_range = format!("bytes */{}", file_size);
492                    return (
493                        StatusCode::RANGE_NOT_SATISFIABLE,
494                        [(header::CONTENT_RANGE, content_range.as_str())],
495                        Body::empty(),
496                    )
497                        .into_response();
498                }
499                Err(_) => {
500                    // 非法 Range 格式,忽略 Range 头,返回完整文件
501                }
502            }
503        }
504    }
505
506    // 7. 完整文件响应(对齐 PHP think-worker sendFile)
507    let content_length_str = file_size.to_string();
508    let mut response = (
509        StatusCode::OK,
510        [
511            (header::CONTENT_TYPE, content_type.as_str()),
512            (header::CONTENT_LENGTH, content_length_str.as_str()),
513            (header::ACCEPT_RANGES, "bytes"),
514        ],
515        Body::from(content),
516    )
517        .into_response();
518
519    // Last-Modified 头(对齐 PHP Last-Modified)
520    if let Some(modified_time) = modified {
521        let last_modified = format_http_date(modified_time);
522        if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
523            response.headers_mut().insert(header::LAST_MODIFIED, val);
524        }
525    }
526
527    // 未知 MIME 类型 → Content-Disposition(对齐 PHP think-worker)
528    if mime.is_none() {
529        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
530            let disposition = format!("attachment; filename=\"{}\"", filename);
531            if let Ok(val) = axum::http::HeaderValue::from_str(&disposition) {
532                response
533                    .headers_mut()
534                    .insert(header::CONTENT_DISPOSITION, val);
535            }
536        }
537    }
538
539    response
540}
541
542/// 静态文件处理器(对齐 PHP think-worker `Http::sendFile` 完整流程)
543///
544/// 将 URI 路径映射到文件系统路径,调用 `serve_file`。
545///
546/// # 参数
547/// - `root` — 静态文件根目录
548/// - `uri_path` — 请求 URI 路径(如 `/static/style.css`)
549/// - `headers` — 请求头
550///
551/// # 返回
552/// - 200/206/304 — 文件响应
553/// - 404 — 文件不存在或路径不安全
554pub async fn static_handler(
555    root: &Path,
556    uri_path: &str,
557    headers: &axum::http::HeaderMap,
558) -> axum::response::Response {
559    use axum::http::StatusCode;
560    use axum::response::IntoResponse;
561
562    // 1. 解析 URL 路径(去掉 query string)
563    let path_only = uri_path.split('?').next().unwrap_or(uri_path);
564
565    // 2. URL 解码(对齐 PHP urldecode)
566    let decoded = percent_decode(path_only);
567
568    // 3. 拼接文件路径
569    // 注意: PathBuf::join 遇到绝对路径(以 `/` 开头)会替换整个 base,
570    // 需先 strip 前导 `/`(对齐 PHP: $file = $this->root . $path 字符串拼接语义)
571    let relative = decoded.trim_start_matches('/');
572    let file_path: PathBuf = root.join(relative);
573
574    // 4. 路径安全验证(防止 ../ 穿越)
575    if !is_path_safe(&file_path, root) {
576        return (StatusCode::NOT_FOUND, "Not found").into_response();
577    }
578
579    // 5. 调用 serve_file
580    serve_file(&file_path, headers).await
581}
582
583/// 简单的 percent-decode 实现(对齐 PHP `urldecode`)
584///
585/// 将 `%XX` 编码序列解码为原始字节。不引入 `urlencoding` 依赖。
586fn percent_decode(input: &str) -> String {
587    let bytes = input.as_bytes();
588    let mut result = Vec::with_capacity(bytes.len());
589
590    let mut i = 0;
591    while i < bytes.len() {
592        if bytes[i] == b'%' && i + 2 < bytes.len() {
593            if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
594                result.push(h * 16 + l);
595                i += 3;
596                continue;
597            }
598        }
599        // 对齐 PHP urldecode: `+` 不转换为空格(PHP urldecode 会转换,但 rawurldecode 不会)
600        // 静态文件路径使用 rawurldecode 语义
601        result.push(bytes[i]);
602        i += 1;
603    }
604
605    String::from_utf8_lossy(&result).into_owned()
606}
607
608/// 十六进制字符 → 数值
609fn hex_digit(b: u8) -> Option<u8> {
610    match b {
611        b'0'..=b'9' => Some(b - b'0'),
612        b'a'..=b'f' => Some(b - b'a' + 10),
613        b'A'..=b'F' => Some(b - b'A' + 10),
614        _ => None,
615    }
616}
617
618// ============================================================================
619// 资源版本化(Cache-Control/ETag)
620// ============================================================================
621
622/// Cache-Control 配置(对齐 nginx `expires` 指令)
623///
624/// 对齐 nginx 配置:
625/// - `expires 1h;` → `Cache-Control: max-age=3600`
626/// - `expires -1;` → `Cache-Control: no-cache`
627/// - `expires off;` → 不设置 Cache-Control
628/// - `add_header Cache-Control "public";` → `Cache-Control: public`
629#[derive(Debug, Clone, Default)]
630pub struct CacheControlConfig {
631    /// max-age(秒),None 表示不设置 max-age
632    pub max_age: Option<u64>,
633    /// public(公共缓存,CDN 可缓存)/ private(仅浏览器缓存)/ None
634    pub visibility: Option<CacheVisibility>,
635    /// no-cache(必须重新验证)/ no-store(完全不缓存)
636    pub no_cache: bool,
637    /// 完全不缓存
638    pub no_store: bool,
639    /// must-revalidate(过期后必须重新验证)
640    pub must_revalidate: bool,
641    /// immutable(文件永不变化,浏览器可永久缓存,对齐前端构建工具指纹 hash 场景)
642    pub immutable: bool,
643}
644
645/// 缓存可见性(对齐 HTTP/1.1 Cache-Control 指令)
646#[derive(Debug, Clone, Copy, PartialEq, Eq)]
647pub enum CacheVisibility {
648    /// `public` — 任何缓存(CDN/代理/浏览器)都可缓存
649    Public,
650    /// `private` — 仅浏览器可缓存(对齐用户特定数据)
651    Private,
652}
653
654impl CacheControlConfig {
655    /// 创建空配置(不设置任何 Cache-Control 指令)
656    pub fn new() -> Self {
657        Self::default()
658    }
659
660    /// 设置 max-age(秒)
661    pub fn with_max_age(mut self, seconds: u64) -> Self {
662        self.max_age = Some(seconds);
663        self
664    }
665
666    /// 设置 public
667    pub fn with_public(mut self) -> Self {
668        self.visibility = Some(CacheVisibility::Public);
669        self
670    }
671
672    /// 设置 private
673    pub fn with_private(mut self) -> Self {
674        self.visibility = Some(CacheVisibility::Private);
675        self
676    }
677
678    /// 设置 no-cache(必须重新验证)
679    pub fn with_no_cache(mut self) -> Self {
680        self.no_cache = true;
681        self
682    }
683
684    /// 设置 no-store(完全不缓存)
685    pub fn with_no_store(mut self) -> Self {
686        self.no_store = true;
687        self
688    }
689
690    /// 设置 must-revalidate
691    pub fn with_must_revalidate(mut self) -> Self {
692        self.must_revalidate = true;
693        self
694    }
695
696    /// 设置 immutable(对齐前端构建工具指纹 hash 场景)
697    pub fn with_immutable(mut self) -> Self {
698        self.immutable = true;
699        self
700    }
701
702    /// 生成 Cache-Control 头值
703    ///
704    /// 返回 `None` 表示配置为空(不设置 Cache-Control 头)。
705    pub fn to_header_value(&self) -> Option<String> {
706        let mut directives = Vec::new();
707
708        if self.no_store {
709            directives.push("no-store".to_string());
710        }
711        if self.no_cache {
712            directives.push("no-cache".to_string());
713        }
714        if let Some(v) = self.visibility {
715            match v {
716                CacheVisibility::Public => directives.push("public".to_string()),
717                CacheVisibility::Private => directives.push("private".to_string()),
718            }
719        }
720        if let Some(max_age) = self.max_age {
721            directives.push(format!("max-age={}", max_age));
722        }
723        if self.must_revalidate {
724            directives.push("must-revalidate".to_string());
725        }
726        if self.immutable {
727            directives.push("immutable".to_string());
728        }
729
730        if directives.is_empty() {
731            None
732        } else {
733            Some(directives.join(", "))
734        }
735    }
736}
737
738/// 生成 weak ETag(对齐 nginx 默认 ETag 行为)
739///
740/// nginx 默认 ETag 格式:`W/"<mtime>-<size>"`,基于文件修改时间和大小。
741/// weak ETag 表示语义相等(内容可能不同但语义相同),适用于 nginx 默认静态文件服务。
742///
743/// # 参数
744/// - `metadata` — 文件元数据(需包含 `modified()` 和 `len()`)
745///
746/// # 返回
747/// - `Some(String)` — ETag 值(如 `W/"1679500000-1024"`)
748/// - `None` — 无法获取修改时间
749pub fn compute_etag(metadata: &std::fs::Metadata) -> Option<String> {
750    let modified = metadata.modified().ok()?;
751    let secs = modified
752        .duration_since(std::time::UNIX_EPOCH)
753        .map(|d| d.as_secs())
754        .unwrap_or(0);
755    let size = metadata.len();
756    Some(format!("W/\"{}-{}\"", secs, size))
757}
758
759/// 计算文件内容的指纹 hash(对齐前端构建工具 `[contenthash]`)
760///
761/// 使用 MD5 算法计算文件内容的 hash,返回 32 位十六进制字符串。
762/// 用于文件名版本化(如 `style.abc123def456.css`),对齐 webpack/vite 的 `[contenthash]`。
763///
764/// # 参数
765/// - `path` — 文件路径
766///
767/// # 返回
768/// - `Ok(String)` — 32 位十六进制 MD5 hash
769/// - `Err(_)` — 文件读取失败
770pub fn fingerprint_file(path: &Path) -> std::io::Result<String> {
771    let content = std::fs::read(path)?;
772    Ok(fingerprint_bytes(&content))
773}
774
775/// 计算字节切片的指纹 hash(对齐前端构建工具 `[contenthash]`)
776///
777/// 使用 MD5 算法计算字节切片的 hash,返回 32 位十六进制字符串。
778pub fn fingerprint_bytes(content: &[u8]) -> String {
779    use md5::{Digest, Md5};
780    let mut hasher = Md5::new();
781    hasher.update(content);
782    let result = hasher.finalize();
783    // 对齐 PHP md5() 输出:32 位小写十六进制
784    let mut hex = String::with_capacity(32);
785    for byte in result.iter() {
786        hex.push_str(&format!("{:02x}", byte));
787    }
788    hex
789}
790
791/// 从版本化路径中提取原始路径(对齐前端构建工具文件名 hash 解析)
792///
793/// 将 `style.abc123def456.css` 解析为 `style.css`,提取并返回指纹 hash。
794///
795/// # 参数
796/// - `path` — 版本化路径(如 `style.abc123.css` 或 `js/app.abc123.js`)
797///
798/// # 返回
799/// - `Some((original_path, hash))` — 原始路径和指纹 hash
800/// - `None` — 路径无扩展名或无指纹 hash
801///
802/// # 示例
803/// ```
804/// # use sz_rust_core::static_files::extract_version_hash;
805/// assert_eq!(
806///     extract_version_hash("style.abc123def456.css"),
807///     Some(("style.css".to_string(), "abc123def456".to_string()))
808/// );
809/// assert_eq!(
810///     extract_version_hash("js/app.abc12345.js"),
811///     Some(("js/app.js".to_string(), "abc12345".to_string()))
812/// );
813/// assert_eq!(extract_version_hash("style.css"), None);
814/// ```
815pub fn extract_version_hash(path: &str) -> Option<(String, String)> {
816    // 查找最后一个 `.` 分隔扩展名
817    let last_dot = path.rfind('.')?;
818    let ext = &path[last_dot + 1..];
819    if ext.is_empty() {
820        return None;
821    }
822
823    // 查找倒数第二个 `.`(分隔文件名和 hash)
824    let stem_with_hash = &path[..last_dot];
825    let second_last_dot = stem_with_hash.rfind('.')?;
826
827    let stem = &stem_with_hash[..second_last_dot];
828    let hash = &stem_with_hash[second_last_dot + 1..];
829
830    // hash 必须是有效的十六进制字符串(至少 8 位)
831    if hash.len() < 8 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
832        return None;
833    }
834
835    Some((format!("{}.{}", stem, ext), hash.to_string()))
836}
837
838/// 自定义文件处理器(带 Cache-Control/ETag,对齐 PHP sendFile + nginx expires)
839///
840/// 在 `serve_file` 基础上扩展:
841/// 1. ETag 生成(对齐 nginx 默认 weak ETag)
842/// 2. If-None-Match 检查(对齐 HTTP/1.1 ETag 语义,304 Not Modified)
843/// 3. Cache-Control 头(对齐 nginx `expires` 指令)
844///
845/// # 参数
846/// - `path` — 文件路径
847/// - `headers` — 请求头(用于 `If-Modified-Since` / `If-None-Match` / `Range`)
848/// - `cache_config` — Cache-Control 配置(`None` 表示不设置 Cache-Control)
849///
850/// # 返回
851/// - 200 OK — 完整文件
852/// - 206 Partial Content — Range 请求
853/// - 304 Not Modified — `If-Modified-Since` 或 `If-None-Match` 匹配
854/// - 404 Not Found — 文件不存在
855/// - 416 Range Not Satisfiable — Range 超出文件大小
856pub async fn serve_file_with_cache(
857    path: &Path,
858    headers: &axum::http::HeaderMap,
859    cache_config: Option<&CacheControlConfig>,
860) -> axum::response::Response {
861    use axum::body::Body;
862    use axum::http::{header, StatusCode};
863    use axum::response::IntoResponse;
864
865    // P1-PATH-01: 防御性路径遍历检查
866    if has_traversal_component(path) {
867        return (StatusCode::NOT_FOUND, "Not found").into_response();
868    }
869
870    // 1. 检查文件存在
871    if !path.is_file() {
872        return (StatusCode::NOT_FOUND, "File not found").into_response();
873    }
874
875    // 2. 读取文件元数据(P1-IO-02:使用 tokio::fs 避免阻塞 async 运行时)
876    let metadata = match tokio::fs::metadata(path).await {
877        Ok(m) => m,
878        Err(_) => {
879            return (
880                StatusCode::INTERNAL_SERVER_ERROR,
881                "Failed to read file metadata",
882            )
883                .into_response();
884        }
885    };
886    let file_size = metadata.len();
887    let modified = metadata.modified().ok();
888
889    // 3. 生成 ETag(对齐 nginx 默认 weak ETag)
890    let etag = compute_etag(&metadata);
891
892    // 4. If-None-Match 检查(对齐 RFC 7232 §6 precondition 顺序)
893    // RFC 7232 §6: If-None-Match 存在时,If-Modified-Since 必须被忽略
894    let mut if_none_match_present = false;
895    if let Some(ref etag_value) = etag {
896        if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
897            if_none_match_present = true;
898            if let Ok(inm_str) = if_none_match.to_str() {
899                // 对齐 HTTP/1.1: If-None-Match 可以是 `*` 或 ETag 列表
900                if inm_str.trim() == "*" || inm_str.trim() == etag_value.as_str() {
901                    let mut response = (
902                        StatusCode::NOT_MODIFIED,
903                        [(header::ETAG, etag_value.as_str())],
904                        Body::empty(),
905                    )
906                        .into_response();
907                    // 304 也应包含 Last-Modified 和 Cache-Control(对齐 nginx 行为)
908                    if let Some(modified_time) = modified {
909                        let last_modified = format_http_date(modified_time);
910                        if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
911                            response.headers_mut().insert(header::LAST_MODIFIED, val);
912                        }
913                    }
914                    if let Some(cc) = cache_config {
915                        if let Some(cc_value) = cc.to_header_value() {
916                            if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
917                                response.headers_mut().insert(header::CACHE_CONTROL, val);
918                            }
919                        }
920                    }
921                    return response;
922                }
923            }
924        }
925    }
926
927    // 5. 304 检查(对齐 PHP If-Modified-Since)
928    // RFC 7232 §6: 仅当 If-None-Match 不存在时才检查 If-Modified-Since
929    if !if_none_match_present {
930        if let Some(modified_time) = modified {
931            let last_modified = format_http_date(modified_time);
932            if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE) {
933                if let Ok(ims_str) = if_modified_since.to_str() {
934                    if ims_str.trim() == last_modified {
935                        let mut response = (
936                            StatusCode::NOT_MODIFIED,
937                            [(header::LAST_MODIFIED, last_modified.as_str())],
938                            Body::empty(),
939                        )
940                            .into_response();
941                        if let Some(ref etag_value) = etag {
942                            if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
943                                response.headers_mut().insert(header::ETAG, val);
944                            }
945                        }
946                        if let Some(cc) = cache_config {
947                            if let Some(cc_value) = cc.to_header_value() {
948                                if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
949                                    response.headers_mut().insert(header::CACHE_CONTROL, val);
950                                }
951                            }
952                        }
953                        return response;
954                    }
955                }
956            }
957        }
958    }
959
960    // 6. MIME 类型识别(对齐 PHP getMimeType)
961    let mime = mime_type_for_path(path);
962    let content_type = mime
963        .clone()
964        .unwrap_or_else(|| "application/octet-stream".to_string());
965
966    // 7. 读取文件内容(P1-IO-02:使用 tokio::fs 避免阻塞 async 运行时)
967    let content = match tokio::fs::read(path).await {
968        Ok(c) => c,
969        Err(_) => {
970            return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
971        }
972    };
973
974    // 8. Range 请求处理(对齐 Webman Range 分支)
975    if let Some(range_header) = headers.get(header::RANGE) {
976        if let Ok(range_str) = range_header.to_str() {
977            match parse_range_header(range_str, file_size) {
978                Ok(range) => {
979                    let content_length = range.end - range.start + 1;
980                    let bytes = content
981                        .get(range.start as usize..=(range.end as usize))
982                        .unwrap_or(&[]);
983                    let content_range =
984                        format!("bytes {}-{}/{}", range.start, range.end, file_size);
985                    let content_length_str = content_length.to_string();
986
987                    let mut response = (
988                        StatusCode::PARTIAL_CONTENT,
989                        [
990                            (header::CONTENT_TYPE, content_type.as_str()),
991                            (header::CONTENT_LENGTH, content_length_str.as_str()),
992                            (header::CONTENT_RANGE, content_range.as_str()),
993                            (header::ACCEPT_RANGES, "bytes"),
994                        ],
995                        Body::from(bytes.to_vec()),
996                    )
997                        .into_response();
998
999                    if let Some(modified_time) = modified {
1000                        let last_modified = format_http_date(modified_time);
1001                        if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
1002                            response.headers_mut().insert(header::LAST_MODIFIED, val);
1003                        }
1004                    }
1005                    if let Some(ref etag_value) = etag {
1006                        if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
1007                            response.headers_mut().insert(header::ETAG, val);
1008                        }
1009                    }
1010                    if let Some(cc) = cache_config {
1011                        if let Some(cc_value) = cc.to_header_value() {
1012                            if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
1013                                response.headers_mut().insert(header::CACHE_CONTROL, val);
1014                            }
1015                        }
1016                    }
1017                    return response;
1018                }
1019                Err(RangeError::Unsatisfiable) => {
1020                    let content_range = format!("bytes */{}", file_size);
1021                    return (
1022                        StatusCode::RANGE_NOT_SATISFIABLE,
1023                        [(header::CONTENT_RANGE, content_range.as_str())],
1024                        Body::empty(),
1025                    )
1026                        .into_response();
1027                }
1028                Err(_) => {
1029                    // 非法 Range 格式,忽略 Range 头,返回完整文件
1030                }
1031            }
1032        }
1033    }
1034
1035    // 9. 完整文件响应(对齐 PHP think-worker sendFile)
1036    let content_length_str = file_size.to_string();
1037    let mut response = (
1038        StatusCode::OK,
1039        [
1040            (header::CONTENT_TYPE, content_type.as_str()),
1041            (header::CONTENT_LENGTH, content_length_str.as_str()),
1042            (header::ACCEPT_RANGES, "bytes"),
1043        ],
1044        Body::from(content),
1045    )
1046        .into_response();
1047
1048    // Last-Modified 头(对齐 PHP Last-Modified)
1049    if let Some(modified_time) = modified {
1050        let last_modified = format_http_date(modified_time);
1051        if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
1052            response.headers_mut().insert(header::LAST_MODIFIED, val);
1053        }
1054    }
1055
1056    // ETag 头(对齐 nginx 默认 ETag)
1057    if let Some(ref etag_value) = etag {
1058        if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
1059            response.headers_mut().insert(header::ETAG, val);
1060        }
1061    }
1062
1063    // Cache-Control 头(对齐 nginx expires)
1064    if let Some(cc) = cache_config {
1065        if let Some(cc_value) = cc.to_header_value() {
1066            if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
1067                response.headers_mut().insert(header::CACHE_CONTROL, val);
1068            }
1069        }
1070    }
1071
1072    // 未知 MIME 类型 → Content-Disposition(对齐 PHP think-worker)
1073    if mime.is_none() {
1074        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
1075            let disposition = format!("attachment; filename=\"{}\"", filename);
1076            if let Ok(val) = axum::http::HeaderValue::from_str(&disposition) {
1077                response
1078                    .headers_mut()
1079                    .insert(header::CONTENT_DISPOSITION, val);
1080            }
1081        }
1082    }
1083
1084    response
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089    use super::*;
1090    use axum::body::Body;
1091    use axum::http::{Method, Request, StatusCode};
1092    use http_body_util::BodyExt;
1093    use std::fs;
1094    use std::path::PathBuf;
1095    use tempfile::TempDir;
1096    use tower::ServiceExt;
1097
1098    /// 创建临时目录并写入测试文件
1099    fn create_test_dir() -> TempDir {
1100        let dir = tempfile::tempdir().expect("failed to create temp dir");
1101        let root = dir.path();
1102
1103        // index.html
1104        fs::write(root.join("index.html"), "<html>index</html>").unwrap();
1105        // style.css
1106        fs::write(root.join("style.css"), "body { color: red; }").unwrap();
1107        // 子目录 + 文件
1108        fs::create_dir_all(root.join("js")).unwrap();
1109        fs::write(root.join("js").join("app.js"), "console.log('hello');").unwrap();
1110        dir
1111    }
1112
1113    async fn send_get(router: Router, uri: &str) -> (StatusCode, Vec<u8>) {
1114        let req = Request::builder()
1115            .method(Method::GET)
1116            .uri(uri)
1117            .body(Body::empty())
1118            .unwrap();
1119        let resp = router.oneshot(req).await.unwrap();
1120        let status = resp.status();
1121        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1122        (status, bytes.to_vec())
1123    }
1124
1125    async fn send_get_with_headers(
1126        router: Router,
1127        uri: &str,
1128    ) -> (StatusCode, axum::http::HeaderMap, Vec<u8>) {
1129        let req = Request::builder()
1130            .method(Method::GET)
1131            .uri(uri)
1132            .body(Body::empty())
1133            .unwrap();
1134        let resp = router.oneshot(req).await.unwrap();
1135        let status = resp.status();
1136        let headers = resp.headers().clone();
1137        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1138        (status, headers, bytes.to_vec())
1139    }
1140
1141    // ====================================================================
1142    // static_router
1143    // ====================================================================
1144
1145    #[tokio::test]
1146    async fn test_static_router_serves_existing_file() {
1147        let dir = create_test_dir();
1148        let router = static_router("/s", dir.path());
1149
1150        let (status, body) = send_get(router, "/s/style.css").await;
1151        assert_eq!(status, StatusCode::OK);
1152        assert_eq!(&body[..], b"body { color: red; }");
1153    }
1154
1155    #[tokio::test]
1156    async fn test_static_router_serves_file_in_subdir() {
1157        let dir = create_test_dir();
1158        let router = static_router("/s", dir.path());
1159
1160        let (status, body) = send_get(router, "/s/js/app.js").await;
1161        assert_eq!(status, StatusCode::OK);
1162        assert_eq!(&body[..], b"console.log('hello');");
1163    }
1164
1165    #[tokio::test]
1166    async fn test_static_router_returns_404_for_missing_file() {
1167        let dir = create_test_dir();
1168        let router = static_router("/s", dir.path());
1169
1170        let (status, _) = send_get(router, "/s/nonexistent.txt").await;
1171        assert_eq!(status, StatusCode::NOT_FOUND);
1172    }
1173
1174    // ====================================================================
1175    // static_router_with_index
1176    // ====================================================================
1177
1178    #[tokio::test]
1179    async fn test_static_router_with_index_serves_index_on_dir() {
1180        let dir = create_test_dir();
1181        let router = static_router_with_index("/s", dir.path());
1182
1183        // 访问 /s/ 应返回 index.html
1184        let (status, body) = send_get(router, "/s/").await;
1185        assert_eq!(status, StatusCode::OK);
1186        assert_eq!(&body[..], b"<html>index</html>");
1187    }
1188
1189    #[tokio::test]
1190    async fn test_static_router_with_index_serves_other_files() {
1191        let dir = create_test_dir();
1192        let router = static_router_with_index("/s", dir.path());
1193
1194        let (status, body) = send_get(router, "/s/style.css").await;
1195        assert_eq!(status, StatusCode::OK);
1196        assert_eq!(&body[..], b"body { color: red; }");
1197    }
1198
1199    // ====================================================================
1200    // static_router_spa
1201    // ====================================================================
1202
1203    #[tokio::test]
1204    async fn test_static_router_spa_fallback_to_index() {
1205        let dir = create_test_dir();
1206        let router = static_router_spa(dir.path());
1207
1208        // 访问不存在的路径 → 回退到 index.html
1209        let (status, body) = send_get(router, "/some/spa/route").await;
1210        assert_eq!(status, StatusCode::OK);
1211        assert_eq!(&body[..], b"<html>index</html>");
1212    }
1213
1214    #[tokio::test]
1215    async fn test_static_router_spa_serves_existing_file() {
1216        let dir = create_test_dir();
1217        let router = static_router_spa(dir.path());
1218
1219        // 已存在的文件仍然优先返回
1220        let (status, body) = send_get(router, "/style.css").await;
1221        assert_eq!(status, StatusCode::OK);
1222        assert_eq!(&body[..], b"body { color: red; }");
1223    }
1224
1225    // ====================================================================
1226    // static_file(单文件)
1227    // ====================================================================
1228
1229    #[tokio::test]
1230    async fn test_static_file_serves_single_file() {
1231        let dir = create_test_dir();
1232        let file_path: PathBuf = dir.path().join("style.css");
1233        let router: Router = Router::new().route_service("/style.css", static_file(file_path));
1234
1235        let (status, body) = send_get(router, "/style.css").await;
1236        assert_eq!(status, StatusCode::OK);
1237        assert_eq!(&body[..], b"body { color: red; }");
1238    }
1239
1240    #[tokio::test]
1241    async fn test_static_file_unknown_path_404() {
1242        let dir = create_test_dir();
1243        let file_path: PathBuf = dir.path().join("style.css");
1244        let router: Router = Router::new().route_service("/style.css", static_file(file_path));
1245
1246        let (status, _) = send_get(router, "/nonexistent.css").await;
1247        assert_eq!(status, StatusCode::NOT_FOUND);
1248    }
1249
1250    // ====================================================================
1251    // 安全性测试:路径穿越
1252    // ====================================================================
1253
1254    #[tokio::test]
1255    async fn test_path_traversal_blocked() {
1256        let dir = create_test_dir();
1257        // 在临时目录外创建一个敏感文件
1258        let parent = dir.path().parent().unwrap();
1259        let sensitive = parent.join("sensitive.txt");
1260        fs::write(&sensitive, "secret").unwrap();
1261
1262        let router = static_router("/s", dir.path());
1263
1264        // 尝试路径穿越
1265        let (status, _) = send_get(router, "/s/../sensitive.txt").await;
1266        // ServeDir 会规范化 URL,应该返回 404 或 400
1267        assert!(
1268            status == StatusCode::NOT_FOUND || status == StatusCode::BAD_REQUEST,
1269            "expected 404 or 400, got {status}"
1270        );
1271
1272        // 清理
1273        let _ = fs::remove_file(&sensitive);
1274    }
1275
1276    // ====================================================================
1277    // content-type 验证
1278    // ====================================================================
1279
1280    #[tokio::test]
1281    async fn test_static_router_sets_content_type_css() {
1282        let dir = create_test_dir();
1283        let router = static_router("/s", dir.path());
1284
1285        let (_, headers, _) = send_get_with_headers(router, "/s/style.css").await;
1286        let ct = headers.get("content-type").unwrap().to_str().unwrap();
1287        assert!(ct.contains("css"), "expected css, got {ct}");
1288    }
1289
1290    #[tokio::test]
1291    async fn test_static_router_sets_content_type_js() {
1292        let dir = create_test_dir();
1293        let router = static_router("/s", dir.path());
1294
1295        let (_, headers, _) = send_get_with_headers(router, "/s/js/app.js").await;
1296        let ct = headers.get("content-type").unwrap().to_str().unwrap();
1297        assert!(
1298            ct.contains("javascript") || ct.contains("js"),
1299            "expected js, got {ct}"
1300        );
1301    }
1302
1303    #[tokio::test]
1304    async fn test_static_router_spa_sets_content_type_html() {
1305        let dir = create_test_dir();
1306        let router = static_router_spa(dir.path());
1307
1308        let (_, headers, _) = send_get_with_headers(router, "/unknown/route").await;
1309        let ct = headers.get("content-type").unwrap().to_str().unwrap();
1310        assert!(ct.contains("html"), "expected html, got {ct}");
1311    }
1312
1313    // ====================================================================
1314    // HEAD 请求
1315    // ====================================================================
1316
1317    #[tokio::test]
1318    async fn test_static_router_handles_head_request() {
1319        let dir = create_test_dir();
1320        let router = static_router("/s", dir.path());
1321
1322        let req = Request::builder()
1323            .method(Method::HEAD)
1324            .uri("/s/style.css")
1325            .body(Body::empty())
1326            .unwrap();
1327        let resp = router.oneshot(req).await.unwrap();
1328        assert_eq!(resp.status(), StatusCode::OK);
1329        // HEAD 响应应该没有 body 或只有少量 body
1330        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1331        assert!(bytes.is_empty() || bytes.len() < 100);
1332    }
1333
1334    // ====================================================================
1335    // 低层 ServeDir API(用于 nest_service)
1336    // ====================================================================
1337
1338    #[tokio::test]
1339    async fn test_static_dir_with_nest_service() {
1340        let dir = create_test_dir();
1341        let router: Router = Router::new().nest_service("/s", static_dir(dir.path()));
1342
1343        let (status, body) = send_get(router, "/s/style.css").await;
1344        assert_eq!(status, StatusCode::OK);
1345        assert_eq!(&body[..], b"body { color: red; }");
1346    }
1347
1348    #[tokio::test]
1349    async fn test_static_dir_with_index_with_nest_service() {
1350        let dir = create_test_dir();
1351        let router: Router = Router::new().nest_service("/s", static_dir_with_index(dir.path()));
1352
1353        let (status, body) = send_get(router, "/s/").await;
1354        assert_eq!(status, StatusCode::OK);
1355        assert_eq!(&body[..], b"<html>index</html>");
1356    }
1357
1358    #[tokio::test]
1359    async fn test_static_dir_spa_with_fallback_service() {
1360        let dir = create_test_dir();
1361        let router: Router = Router::new().fallback_service(static_dir_spa(dir.path()));
1362
1363        let (status, body) = send_get(router, "/unknown/route").await;
1364        assert_eq!(status, StatusCode::OK);
1365        assert_eq!(&body[..], b"<html>index</html>");
1366    }
1367
1368    // ====================================================================
1369    // 合并到现有 Router
1370    // ====================================================================
1371
1372    #[tokio::test]
1373    async fn test_static_router_merge_with_api_routes() {
1374        let dir = create_test_dir();
1375
1376        let api_router: Router = Router::new().route(
1377            "/api/hello",
1378            axum::routing::get(|| async { "hello from api" }),
1379        );
1380        let static_router = static_router("/static", dir.path());
1381
1382        let app: Router = api_router.merge(static_router);
1383
1384        // API 路由仍可用
1385        let (status, body) = send_get(app.clone(), "/api/hello").await;
1386        assert_eq!(status, StatusCode::OK);
1387        assert_eq!(&body[..], b"hello from api");
1388
1389        // 静态文件可用
1390        let (status, body) = send_get(app, "/static/style.css").await;
1391        assert_eq!(status, StatusCode::OK);
1392        assert_eq!(&body[..], b"body { color: red; }");
1393    }
1394
1395    // ====================================================================
1396    // MIME 类型表测试(对齐 PHP $mimeTypeMap)
1397    // ====================================================================
1398
1399    #[test]
1400    fn test_mime_type_for_extension_html() {
1401        assert_eq!(mime_type_for_extension("html"), Some("text/html"));
1402        assert_eq!(mime_type_for_extension("HTML"), Some("text/html"));
1403        assert_eq!(mime_type_for_extension("Htm"), Some("text/html"));
1404    }
1405
1406    #[test]
1407    fn test_mime_type_for_extension_css() {
1408        assert_eq!(mime_type_for_extension("css"), Some("text/css"));
1409    }
1410
1411    #[test]
1412    fn test_mime_type_for_extension_js() {
1413        assert_eq!(
1414            mime_type_for_extension("js"),
1415            Some("application/javascript")
1416        );
1417        assert_eq!(
1418            mime_type_for_extension("mjs"),
1419            Some("application/javascript")
1420        );
1421    }
1422
1423    #[test]
1424    fn test_mime_type_for_extension_json() {
1425        assert_eq!(mime_type_for_extension("json"), Some("application/json"));
1426    }
1427
1428    #[test]
1429    fn test_mime_type_for_extension_images() {
1430        assert_eq!(mime_type_for_extension("png"), Some("image/png"));
1431        assert_eq!(mime_type_for_extension("jpg"), Some("image/jpeg"));
1432        assert_eq!(mime_type_for_extension("jpeg"), Some("image/jpeg"));
1433        assert_eq!(mime_type_for_extension("gif"), Some("image/gif"));
1434        assert_eq!(mime_type_for_extension("svg"), Some("image/svg+xml"));
1435        assert_eq!(mime_type_for_extension("ico"), Some("image/x-icon"));
1436        assert_eq!(mime_type_for_extension("webp"), Some("image/webp"));
1437    }
1438
1439    #[test]
1440    fn test_mime_type_for_extension_fonts() {
1441        assert_eq!(mime_type_for_extension("woff"), Some("font/woff"));
1442        assert_eq!(mime_type_for_extension("woff2"), Some("font/woff2"));
1443        assert_eq!(mime_type_for_extension("ttf"), Some("font/ttf"));
1444    }
1445
1446    #[test]
1447    fn test_mime_type_for_extension_unknown() {
1448        assert_eq!(mime_type_for_extension("xyz123"), None);
1449        assert_eq!(mime_type_for_extension(""), None);
1450    }
1451
1452    #[test]
1453    fn test_mime_type_for_path() {
1454        assert_eq!(
1455            mime_type_for_path(Path::new("style.css")),
1456            Some("text/css".to_string())
1457        );
1458        assert_eq!(
1459            mime_type_for_path(Path::new("/var/www/index.html")),
1460            Some("text/html".to_string())
1461        );
1462        // 未知扩展名走 mime_guess 后备
1463        let result = mime_type_for_path(Path::new("file.unknownext123"));
1464        // mime_guess 可能返回 None 或某些类型,不强断言
1465        let _ = result;
1466    }
1467
1468    // ====================================================================
1469    // Range 头解析测试(对齐 Webman sendFile Range 分支)
1470    // ====================================================================
1471
1472    #[test]
1473    fn test_parse_range_start_end() {
1474        // bytes=0-499
1475        let range = parse_range_header("bytes=0-499", 1000).unwrap();
1476        assert_eq!(range, RangeSpec { start: 0, end: 499 });
1477    }
1478
1479    #[test]
1480    fn test_parse_range_start_open() {
1481        // bytes=500-(从 500 到末尾)
1482        let range = parse_range_header("bytes=500-", 1000).unwrap();
1483        assert_eq!(
1484            range,
1485            RangeSpec {
1486                start: 500,
1487                end: 999
1488            }
1489        );
1490    }
1491
1492    #[test]
1493    fn test_parse_range_suffix() {
1494        // bytes=-500(最后 500 字节)
1495        let range = parse_range_header("bytes=-500", 1000).unwrap();
1496        assert_eq!(
1497            range,
1498            RangeSpec {
1499                start: 500,
1500                end: 999
1501            }
1502        );
1503    }
1504
1505    #[test]
1506    fn test_parse_range_suffix_larger_than_file() {
1507        // bytes=-2000(suffix > file_size → 返回整个文件)
1508        let range = parse_range_header("bytes=-2000", 1000).unwrap();
1509        assert_eq!(range, RangeSpec { start: 0, end: 999 });
1510    }
1511
1512    #[test]
1513    fn test_parse_range_end_exceeds_file_size() {
1514        // bytes=900-2000(end > file_size → 截断到 file_size - 1)
1515        let range = parse_range_header("bytes=900-2000", 1000).unwrap();
1516        assert_eq!(
1517            range,
1518            RangeSpec {
1519                start: 900,
1520                end: 999
1521            }
1522        );
1523    }
1524
1525    #[test]
1526    fn test_parse_range_start_equals_file_size() {
1527        // bytes=1000-(start == file_size → Unsatisfiable)
1528        let result = parse_range_header("bytes=1000-", 1000);
1529        assert_eq!(result, Err(RangeError::Unsatisfiable));
1530    }
1531
1532    #[test]
1533    fn test_parse_range_start_greater_than_end() {
1534        // bytes=500-100(start > end → InvalidRange)
1535        let result = parse_range_header("bytes=500-100", 1000);
1536        assert_eq!(result, Err(RangeError::InvalidRange));
1537    }
1538
1539    #[test]
1540    fn test_parse_range_invalid_format_no_bytes_prefix() {
1541        let result = parse_range_header("0-499", 1000);
1542        assert_eq!(result, Err(RangeError::InvalidFormat));
1543    }
1544
1545    #[test]
1546    fn test_parse_range_invalid_format_no_dash() {
1547        let result = parse_range_header("bytes=500", 1000);
1548        assert_eq!(result, Err(RangeError::InvalidFormat));
1549    }
1550
1551    #[test]
1552    fn test_parse_range_empty_range() {
1553        // bytes=- (空范围 → InvalidRange)
1554        let result = parse_range_header("bytes=-", 1000);
1555        assert_eq!(result, Err(RangeError::InvalidRange));
1556    }
1557
1558    #[test]
1559    fn test_parse_range_non_numeric() {
1560        let result = parse_range_header("bytes=abc-500", 1000);
1561        assert_eq!(result, Err(RangeError::InvalidRange));
1562    }
1563
1564    #[test]
1565    fn test_parse_range_with_whitespace() {
1566        // 带空格的 Range 头(trim 后解析)
1567        let range = parse_range_header("  bytes=0-499  ", 1000).unwrap();
1568        assert_eq!(range, RangeSpec { start: 0, end: 499 });
1569    }
1570
1571    // ====================================================================
1572    // 路径安全验证测试
1573    // ====================================================================
1574
1575    #[test]
1576    fn test_is_path_safe_valid() {
1577        let dir = create_test_dir();
1578        let root = dir.path();
1579        let file = root.join("style.css");
1580        assert!(is_path_safe(&file, root));
1581    }
1582
1583    #[test]
1584    fn test_is_path_safe_subdir() {
1585        let dir = create_test_dir();
1586        let root = dir.path();
1587        let file = root.join("js").join("app.js");
1588        assert!(is_path_safe(&file, root));
1589    }
1590
1591    #[test]
1592    fn test_is_path_safe_traversal_blocked() {
1593        let dir = create_test_dir();
1594        let root = dir.path();
1595        // 创建一个子目录外的文件
1596        let parent = root.parent().unwrap();
1597        let sensitive = parent.join("sensitive.txt");
1598        fs::write(&sensitive, "secret").unwrap();
1599
1600        // 尝试通过 ../ 访问敏感文件
1601        let file = root.join("..").join("sensitive.txt");
1602        assert!(!is_path_safe(&file, root));
1603
1604        let _ = fs::remove_file(&sensitive);
1605    }
1606
1607    #[test]
1608    fn test_is_path_safe_nonexistent() {
1609        let dir = create_test_dir();
1610        let root = dir.path();
1611        let file = root.join("nonexistent.txt");
1612        // 不存在的文件,canonicalize 失败 → false
1613        assert!(!is_path_safe(&file, root));
1614    }
1615
1616    // ====================================================================
1617    // percent_decode 测试(对齐 PHP rawurldecode)
1618    // ====================================================================
1619
1620    #[test]
1621    fn test_percent_decode_plain() {
1622        assert_eq!(percent_decode("/style.css"), "/style.css");
1623    }
1624
1625    #[test]
1626    fn test_percent_decode_encoded() {
1627        // %20 = 空格
1628        assert_eq!(percent_decode("/my%20file.css"), "/my file.css");
1629    }
1630
1631    #[test]
1632    fn test_percent_decode_unicode() {
1633        // %E4%B8%AD = "中"
1634        assert_eq!(percent_decode("/%E4%B8%AD.html"), "/中.html");
1635    }
1636
1637    #[test]
1638    fn test_percent_decode_no_plus_conversion() {
1639        // rawurldecode 语义:+ 不转换为空格
1640        assert_eq!(percent_decode("/my+file.css"), "/my+file.css");
1641    }
1642
1643    #[test]
1644    fn test_percent_decode_incomplete() {
1645        // 不完整的 %XX 序列,保留原样
1646        assert_eq!(percent_decode("/file%2.css"), "/file%2.css");
1647    }
1648
1649    // ====================================================================
1650    // serve_file 测试(对齐 PHP think-worker sendFile)
1651    // ====================================================================
1652
1653    #[tokio::test]
1654    async fn test_serve_file_basic() {
1655        let dir = create_test_dir();
1656        let file_path = dir.path().join("style.css");
1657        let headers = axum::http::HeaderMap::new();
1658
1659        let resp = serve_file(&file_path, &headers).await;
1660        assert_eq!(resp.status(), StatusCode::OK);
1661
1662        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1663        assert_eq!(&bytes[..], b"body { color: red; }");
1664    }
1665
1666    #[tokio::test]
1667    async fn test_serve_file_not_found() {
1668        let dir = create_test_dir();
1669        let file_path = dir.path().join("nonexistent.txt");
1670        let headers = axum::http::HeaderMap::new();
1671
1672        let resp = serve_file(&file_path, &headers).await;
1673        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1674    }
1675
1676    #[tokio::test]
1677    async fn test_serve_file_sets_content_type() {
1678        let dir = create_test_dir();
1679        let file_path = dir.path().join("style.css");
1680        let headers = axum::http::HeaderMap::new();
1681
1682        let resp = serve_file(&file_path, &headers).await;
1683        let ct = resp
1684            .headers()
1685            .get("content-type")
1686            .unwrap()
1687            .to_str()
1688            .unwrap();
1689        assert!(ct.contains("css"), "expected css, got {ct}");
1690    }
1691
1692    #[tokio::test]
1693    async fn test_serve_file_sets_last_modified() {
1694        let dir = create_test_dir();
1695        let file_path = dir.path().join("style.css");
1696        let headers = axum::http::HeaderMap::new();
1697
1698        let resp = serve_file(&file_path, &headers).await;
1699        let lm = resp.headers().get("last-modified");
1700        assert!(lm.is_some(), "Last-Modified header should be set");
1701        let lm_str = lm.unwrap().to_str().unwrap();
1702        assert!(lm_str.ends_with("GMT"), "Last-Modified should end with GMT");
1703    }
1704
1705    #[tokio::test]
1706    async fn test_serve_file_sets_accept_ranges() {
1707        let dir = create_test_dir();
1708        let file_path = dir.path().join("style.css");
1709        let headers = axum::http::HeaderMap::new();
1710
1711        let resp = serve_file(&file_path, &headers).await;
1712        let ar = resp
1713            .headers()
1714            .get("accept-ranges")
1715            .unwrap()
1716            .to_str()
1717            .unwrap();
1718        assert_eq!(ar, "bytes");
1719    }
1720
1721    #[tokio::test]
1722    async fn test_serve_file_304_if_modified_since_match() {
1723        let dir = create_test_dir();
1724        let file_path = dir.path().join("style.css");
1725
1726        // 第一次请求获取 Last-Modified
1727        let headers1 = axum::http::HeaderMap::new();
1728        let resp1 = serve_file(&file_path, &headers1).await;
1729        let last_modified = resp1
1730            .headers()
1731            .get("last-modified")
1732            .unwrap()
1733            .to_str()
1734            .unwrap()
1735            .to_string();
1736
1737        // 第二次请求带 If-Modified-Since
1738        let mut headers2 = axum::http::HeaderMap::new();
1739        headers2.insert(
1740            axum::http::header::IF_MODIFIED_SINCE,
1741            axum::http::HeaderValue::from_str(&last_modified).unwrap(),
1742        );
1743        let resp2 = serve_file(&file_path, &headers2).await;
1744        assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
1745
1746        let bytes = resp2.into_body().collect().await.unwrap().to_bytes();
1747        assert!(bytes.is_empty(), "304 response should have empty body");
1748    }
1749
1750    #[tokio::test]
1751    async fn test_serve_file_304_if_modified_since_mismatch() {
1752        let dir = create_test_dir();
1753        let file_path = dir.path().join("style.css");
1754
1755        let mut headers = axum::http::HeaderMap::new();
1756        headers.insert(
1757            axum::http::header::IF_MODIFIED_SINCE,
1758            axum::http::HeaderValue::from_static("Mon, 01 Jan 2000 00:00:00 GMT"),
1759        );
1760        let resp = serve_file(&file_path, &headers).await;
1761        assert_eq!(resp.status(), StatusCode::OK);
1762    }
1763
1764    // ====================================================================
1765    // serve_file Range 请求测试(对齐 Webman 206 Partial Content)
1766    // ====================================================================
1767
1768    #[tokio::test]
1769    async fn test_serve_file_range_partial_content() {
1770        let dir = create_test_dir();
1771        // 创建一个有明确内容的文件
1772        let file_path = dir.path().join("data.bin");
1773        fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); // 20 字节
1774
1775        let mut headers = axum::http::HeaderMap::new();
1776        headers.insert(
1777            axum::http::header::RANGE,
1778            axum::http::HeaderValue::from_static("bytes=5-9"),
1779        );
1780
1781        let resp = serve_file(&file_path, &headers).await;
1782        assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1783
1784        let cr = resp
1785            .headers()
1786            .get("content-range")
1787            .unwrap()
1788            .to_str()
1789            .unwrap();
1790        assert_eq!(cr, "bytes 5-9/20");
1791
1792        let cl = resp
1793            .headers()
1794            .get("content-length")
1795            .unwrap()
1796            .to_str()
1797            .unwrap();
1798        assert_eq!(cl, "5");
1799
1800        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1801        assert_eq!(&bytes[..], b"56789");
1802    }
1803
1804    #[tokio::test]
1805    async fn test_serve_file_range_open_end() {
1806        let dir = create_test_dir();
1807        let file_path = dir.path().join("data.bin");
1808        fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); // 20 字节
1809
1810        let mut headers = axum::http::HeaderMap::new();
1811        headers.insert(
1812            axum::http::header::RANGE,
1813            axum::http::HeaderValue::from_static("bytes=10-"),
1814        );
1815
1816        let resp = serve_file(&file_path, &headers).await;
1817        assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1818
1819        let cr = resp
1820            .headers()
1821            .get("content-range")
1822            .unwrap()
1823            .to_str()
1824            .unwrap();
1825        assert_eq!(cr, "bytes 10-19/20");
1826
1827        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1828        assert_eq!(&bytes[..], b"ABCDEFGHIJ");
1829    }
1830
1831    #[tokio::test]
1832    async fn test_serve_file_range_suffix() {
1833        let dir = create_test_dir();
1834        let file_path = dir.path().join("data.bin");
1835        fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); // 20 字节
1836
1837        let mut headers = axum::http::HeaderMap::new();
1838        headers.insert(
1839            axum::http::header::RANGE,
1840            axum::http::HeaderValue::from_static("bytes=-5"),
1841        );
1842
1843        let resp = serve_file(&file_path, &headers).await;
1844        assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1845
1846        let cr = resp
1847            .headers()
1848            .get("content-range")
1849            .unwrap()
1850            .to_str()
1851            .unwrap();
1852        assert_eq!(cr, "bytes 15-19/20");
1853
1854        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1855        assert_eq!(&bytes[..], b"FGHIJ");
1856    }
1857
1858    #[tokio::test]
1859    async fn test_serve_file_range_unsatisfiable() {
1860        let dir = create_test_dir();
1861        let file_path = dir.path().join("data.bin");
1862        fs::write(&file_path, b"0123456789").unwrap(); // 10 字节
1863
1864        let mut headers = axum::http::HeaderMap::new();
1865        headers.insert(
1866            axum::http::header::RANGE,
1867            axum::http::HeaderValue::from_static("bytes=100-200"),
1868        );
1869
1870        let resp = serve_file(&file_path, &headers).await;
1871        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1872
1873        let cr = resp
1874            .headers()
1875            .get("content-range")
1876            .unwrap()
1877            .to_str()
1878            .unwrap();
1879        assert_eq!(cr, "bytes */10");
1880    }
1881
1882    #[tokio::test]
1883    async fn test_serve_file_range_invalid_fallback_to_full() {
1884        let dir = create_test_dir();
1885        let file_path = dir.path().join("data.bin");
1886        fs::write(&file_path, b"0123456789").unwrap(); // 10 字节
1887
1888        let mut headers = axum::http::HeaderMap::new();
1889        // 非法 Range 格式(无 bytes= 前缀)
1890        headers.insert(
1891            axum::http::header::RANGE,
1892            axum::http::HeaderValue::from_static("0-499"),
1893        );
1894
1895        let resp = serve_file(&file_path, &headers).await;
1896        // 非法格式忽略 Range,返回完整文件
1897        assert_eq!(resp.status(), StatusCode::OK);
1898
1899        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1900        assert_eq!(&bytes[..], b"0123456789");
1901    }
1902
1903    // ====================================================================
1904    // static_handler 测试(对齐 PHP think-worker Http::sendFile 完整流程)
1905    // ====================================================================
1906
1907    #[tokio::test]
1908    async fn test_static_handler_serves_file() {
1909        let dir = create_test_dir();
1910        let headers = axum::http::HeaderMap::new();
1911
1912        let resp = static_handler(dir.path(), "/style.css", &headers).await;
1913        assert_eq!(resp.status(), StatusCode::OK);
1914
1915        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1916        assert_eq!(&bytes[..], b"body { color: red; }");
1917    }
1918
1919    #[tokio::test]
1920    async fn test_static_handler_serves_subdir_file() {
1921        let dir = create_test_dir();
1922        let headers = axum::http::HeaderMap::new();
1923
1924        let resp = static_handler(dir.path(), "/js/app.js", &headers).await;
1925        assert_eq!(resp.status(), StatusCode::OK);
1926
1927        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1928        assert_eq!(&bytes[..], b"console.log('hello');");
1929    }
1930
1931    #[tokio::test]
1932    async fn test_static_handler_404_for_missing() {
1933        let dir = create_test_dir();
1934        let headers = axum::http::HeaderMap::new();
1935
1936        let resp = static_handler(dir.path(), "/nonexistent.txt", &headers).await;
1937        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1938    }
1939
1940    #[tokio::test]
1941    async fn test_static_handler_blocks_traversal() {
1942        let dir = create_test_dir();
1943        let root = dir.path();
1944        let parent = root.parent().unwrap();
1945        let sensitive = parent.join("secret.txt");
1946        fs::write(&sensitive, "secret").unwrap();
1947
1948        let headers = axum::http::HeaderMap::new();
1949        let resp = static_handler(root, "/../secret.txt", &headers).await;
1950        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1951
1952        let _ = fs::remove_file(&sensitive);
1953    }
1954
1955    // ====================================================================
1956    // P1-PATH-01: serve_file / serve_file_with_cache 防御性路径遍历检查
1957    // ====================================================================
1958
1959    #[tokio::test]
1960    async fn test_p1_path_01_serve_file_rejects_parent_dir_component() {
1961        // 即使目标文件实际存在,包含 .. 的路径也应被拒绝
1962        let dir = create_test_dir();
1963        // 构造一个包含 .. 但实际指向有效文件的路径
1964        let file_path = dir.path().join("subdir/../style.css");
1965        // 先确保文件存在(subdir/style.css 不存在,但 style.css 在根目录)
1966        // 这里 .. 解析后指向 style.css,但 serve_file 应在解析前拒绝
1967        let headers = axum::http::HeaderMap::new();
1968        let resp = serve_file(&file_path, &headers).await;
1969        assert_eq!(
1970            resp.status(),
1971            StatusCode::NOT_FOUND,
1972            "P1-PATH-01: serve_file 应拒绝包含 .. 组件的路径,即使解析后文件存在"
1973        );
1974    }
1975
1976    #[tokio::test]
1977    async fn test_p1_path_01_serve_file_with_cache_rejects_parent_dir_component() {
1978        let dir = create_test_dir();
1979        let file_path = dir.path().join("subdir/../style.css");
1980        let headers = axum::http::HeaderMap::new();
1981        let resp = serve_file_with_cache(&file_path, &headers, None).await;
1982        assert_eq!(
1983            resp.status(),
1984            StatusCode::NOT_FOUND,
1985            "P1-PATH-01: serve_file_with_cache 应拒绝包含 .. 组件的路径"
1986        );
1987    }
1988
1989    #[tokio::test]
1990    async fn test_p1_path_01_serve_file_allows_clean_path() {
1991        // 确保正常路径不受影响
1992        let dir = create_test_dir();
1993        let file_path = dir.path().join("style.css");
1994        let headers = axum::http::HeaderMap::new();
1995        let resp = serve_file(&file_path, &headers).await;
1996        assert_eq!(
1997            resp.status(),
1998            StatusCode::OK,
1999            "P1-PATH-01: 不含 .. 的正常路径应正常工作"
2000        );
2001    }
2002
2003    #[tokio::test]
2004    async fn test_p1_path_01_serve_file_rejects_deep_traversal() {
2005        // 多层 .. 也应被拒绝
2006        let dir = create_test_dir();
2007        let file_path = dir.path().join("a/../../b/../../etc/passwd");
2008        let headers = axum::http::HeaderMap::new();
2009        let resp = serve_file(&file_path, &headers).await;
2010        assert_eq!(
2011            resp.status(),
2012            StatusCode::NOT_FOUND,
2013            "P1-PATH-01: 多层 .. 路径应被拒绝"
2014        );
2015    }
2016
2017    #[tokio::test]
2018    async fn test_static_handler_with_query_string() {
2019        let dir = create_test_dir();
2020        let headers = axum::http::HeaderMap::new();
2021
2022        // 带 query string 的请求
2023        let resp = static_handler(dir.path(), "/style.css?v=123", &headers).await;
2024        assert_eq!(resp.status(), StatusCode::OK);
2025
2026        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2027        assert_eq!(&bytes[..], b"body { color: red; }");
2028    }
2029
2030    #[tokio::test]
2031    async fn test_static_handler_url_encoded_path() {
2032        let dir = create_test_dir();
2033        // 创建一个带空格的文件名
2034        fs::write(dir.path().join("my file.css"), "encoded content").unwrap();
2035
2036        let headers = axum::http::HeaderMap::new();
2037        // %20 = 空格
2038        let resp = static_handler(dir.path(), "/my%20file.css", &headers).await;
2039        assert_eq!(resp.status(), StatusCode::OK);
2040
2041        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2042        assert_eq!(&bytes[..], b"encoded content");
2043    }
2044
2045    // ====================================================================
2046    // format_http_date 测试
2047    // ====================================================================
2048
2049    #[test]
2050    fn test_format_http_date_epoch() {
2051        // Unix epoch: 1970-01-01 00:00:00 GMT (星期四)
2052        let time = std::time::UNIX_EPOCH;
2053        let date_str = format_http_date(time);
2054        assert!(
2055            date_str.contains("Thu"),
2056            "expected Thursday, got {date_str}"
2057        );
2058        assert!(date_str.contains("01"), "expected day 01, got {date_str}");
2059        assert!(date_str.contains("Jan"), "expected January, got {date_str}");
2060        assert!(
2061            date_str.contains("1970"),
2062            "expected year 1970, got {date_str}"
2063        );
2064        assert!(
2065            date_str.ends_with("GMT"),
2066            "expected GMT suffix, got {date_str}"
2067        );
2068    }
2069
2070    #[test]
2071    fn test_format_http_date_known_timestamp() {
2072        // 2026-01-15 12:30:45 UTC
2073        let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1768569045);
2074        let date_str = format_http_date(time);
2075        assert!(
2076            date_str.contains("2026"),
2077            "expected year 2026, got {date_str}"
2078        );
2079        assert!(
2080            date_str.ends_with("GMT"),
2081            "expected GMT suffix, got {date_str}"
2082        );
2083    }
2084
2085    // ====================================================================
2086    // 未知 MIME 类型 → Content-Disposition 测试(对齐 PHP think-worker)
2087    // ====================================================================
2088
2089    #[tokio::test]
2090    async fn test_serve_file_unknown_mime_sets_content_disposition() {
2091        let dir = create_test_dir();
2092        // 创建一个未知扩展名的文件
2093        let file_path = dir.path().join("data.xyz123");
2094        fs::write(&file_path, "unknown content").unwrap();
2095
2096        let headers = axum::http::HeaderMap::new();
2097        let resp = serve_file(&file_path, &headers).await;
2098        assert_eq!(resp.status(), StatusCode::OK);
2099
2100        // 未知 MIME 类型应设置 Content-Disposition
2101        let cd = resp.headers().get("content-disposition");
2102        assert!(
2103            cd.is_some(),
2104            "Content-Disposition should be set for unknown MIME"
2105        );
2106        let cd_str = cd.unwrap().to_str().unwrap();
2107        assert!(
2108            cd_str.contains("attachment"),
2109            "expected attachment, got {cd_str}"
2110        );
2111        assert!(
2112            cd_str.contains("data.xyz123"),
2113            "expected filename, got {cd_str}"
2114        );
2115    }
2116
2117    #[tokio::test]
2118    async fn test_serve_file_known_mime_no_content_disposition() {
2119        let dir = create_test_dir();
2120        let file_path = dir.path().join("style.css");
2121        let headers = axum::http::HeaderMap::new();
2122
2123        let resp = serve_file(&file_path, &headers).await;
2124        assert_eq!(resp.status(), StatusCode::OK);
2125
2126        // 已知 MIME 类型不应设置 Content-Disposition
2127        let cd = resp.headers().get("content-disposition");
2128        assert!(
2129            cd.is_none(),
2130            "Content-Disposition should not be set for known MIME"
2131        );
2132    }
2133
2134    // ====================================================================
2135    // CacheControlConfig 测试(对齐 nginx expires 指令)
2136    // ====================================================================
2137
2138    #[test]
2139    fn test_cache_control_default_empty() {
2140        // 默认配置(空)不应产生 Cache-Control 头
2141        let config = CacheControlConfig::new();
2142        assert_eq!(config.to_header_value(), None);
2143    }
2144
2145    #[test]
2146    fn test_cache_control_max_age_only() {
2147        // 对齐 nginx `expires 1h;` → max-age=3600
2148        let config = CacheControlConfig::new().with_max_age(3600);
2149        assert_eq!(config.to_header_value().as_deref(), Some("max-age=3600"));
2150    }
2151
2152    #[test]
2153    fn test_cache_control_public_max_age() {
2154        // 对齐 nginx `expires 1h; add_header Cache-Control "public";`
2155        let config = CacheControlConfig::new().with_public().with_max_age(3600);
2156        assert_eq!(
2157            config.to_header_value().as_deref(),
2158            Some("public, max-age=3600")
2159        );
2160    }
2161
2162    #[test]
2163    fn test_cache_control_private_max_age() {
2164        let config = CacheControlConfig::new().with_private().with_max_age(600);
2165        assert_eq!(
2166            config.to_header_value().as_deref(),
2167            Some("private, max-age=600")
2168        );
2169    }
2170
2171    #[test]
2172    fn test_cache_control_no_cache() {
2173        // 对齐 nginx `expires -1;` → no-cache
2174        let config = CacheControlConfig::new().with_no_cache();
2175        assert_eq!(config.to_header_value().as_deref(), Some("no-cache"));
2176    }
2177
2178    #[test]
2179    fn test_cache_control_no_store() {
2180        let config = CacheControlConfig::new().with_no_store();
2181        assert_eq!(config.to_header_value().as_deref(), Some("no-store"));
2182    }
2183
2184    #[test]
2185    fn test_cache_control_no_store_no_cache_order() {
2186        // 验证 no-store 在 no-cache 之前(to_header_value 实现顺序)
2187        let config = CacheControlConfig::new().with_no_cache().with_no_store();
2188        assert_eq!(
2189            config.to_header_value().as_deref(),
2190            Some("no-store, no-cache")
2191        );
2192    }
2193
2194    #[test]
2195    fn test_cache_control_must_revalidate() {
2196        let config = CacheControlConfig::new()
2197            .with_no_cache()
2198            .with_must_revalidate();
2199        assert_eq!(
2200            config.to_header_value().as_deref(),
2201            Some("no-cache, must-revalidate")
2202        );
2203    }
2204
2205    #[test]
2206    fn test_cache_control_immutable_long_max_age() {
2207        // 对齐前端构建工具指纹 hash 场景:public, max-age=31536000, immutable
2208        let config = CacheControlConfig::new()
2209            .with_public()
2210            .with_max_age(31536000)
2211            .with_immutable();
2212        assert_eq!(
2213            config.to_header_value().as_deref(),
2214            Some("public, max-age=31536000, immutable")
2215        );
2216    }
2217
2218    #[test]
2219    fn test_cache_control_full_directive_order() {
2220        // 验证所有指令的顺序:no-store, no-cache, public/private, max-age, must-revalidate, immutable
2221        let config = CacheControlConfig::new()
2222            .with_no_store()
2223            .with_no_cache()
2224            .with_public()
2225            .with_max_age(60)
2226            .with_must_revalidate()
2227            .with_immutable();
2228        assert_eq!(
2229            config.to_header_value().as_deref(),
2230            Some("no-store, no-cache, public, max-age=60, must-revalidate, immutable")
2231        );
2232    }
2233
2234    // ====================================================================
2235    // compute_etag 测试(对齐 nginx 默认 ETag)
2236    // ====================================================================
2237
2238    #[test]
2239    fn test_compute_etag_format() {
2240        // 验证 ETag 格式:W/"<mtime>-<size>"
2241        let dir = create_test_dir();
2242        let file_path = dir.path().join("style.css");
2243        let metadata = std::fs::metadata(&file_path).unwrap();
2244
2245        let etag = compute_etag(&metadata).expect("ETag should be computed");
2246        assert!(
2247            etag.starts_with("W/\"") && etag.ends_with('"'),
2248            "ETag should be weak format W/\"...\", got: {etag}"
2249        );
2250        // 格式 W/"<digits>-<digits>"
2251        let inner = &etag[3..etag.len() - 1];
2252        let parts: Vec<&str> = inner.splitn(2, '-').collect();
2253        assert_eq!(parts.len(), 2, "ETag inner should be <mtime>-<size>");
2254        assert!(
2255            parts[0].chars().all(|c| c.is_ascii_digit()),
2256            "mtime should be numeric"
2257        );
2258        assert!(
2259            parts[1].chars().all(|c| c.is_ascii_digit()),
2260            "size should be numeric"
2261        );
2262    }
2263
2264    #[test]
2265    fn test_compute_etag_size_in_header() {
2266        // 验证 ETag 包含文件大小
2267        let dir = create_test_dir();
2268        let file_path = dir.path().join("data.bin");
2269        fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); // 20 字节
2270        let metadata = std::fs::metadata(&file_path).unwrap();
2271
2272        let etag = compute_etag(&metadata).unwrap();
2273        assert!(
2274            etag.contains("-20\""),
2275            "ETag should contain file size 20, got: {etag}"
2276        );
2277    }
2278
2279    #[test]
2280    fn test_compute_etag_different_sizes_differ() {
2281        let dir = create_test_dir();
2282        let small_path = dir.path().join("small.bin");
2283        let large_path = dir.path().join("large.bin");
2284        fs::write(&small_path, b"short").unwrap();
2285        fs::write(&large_path, b"this is a much longer file content").unwrap();
2286
2287        let small_etag = compute_etag(&std::fs::metadata(&small_path).unwrap()).unwrap();
2288        let large_etag = compute_etag(&std::fs::metadata(&large_path).unwrap()).unwrap();
2289        assert_ne!(
2290            small_etag, large_etag,
2291            "Different file sizes should produce different ETags"
2292        );
2293    }
2294
2295    // ====================================================================
2296    // fingerprint_bytes / fingerprint_file 测试
2297    //         (对齐 PHP md5() + 前端构建工具 [contenthash])
2298    // ====================================================================
2299
2300    #[test]
2301    fn test_fingerprint_bytes_empty() {
2302        // MD5("") = d41d8cd98f00b204e9800998ecf8427e(对齐 PHP md5(""))
2303        let hash = fingerprint_bytes(b"");
2304        assert_eq!(hash, "d41d8cd98f00b204e9800998ecf8427e");
2305        assert_eq!(hash.len(), 32, "MD5 hash should be 32 hex chars");
2306    }
2307
2308    #[test]
2309    fn test_fingerprint_bytes_hello() {
2310        // MD5("hello") = 5d41402abc4b2a76b9719d911017c592(对齐 PHP md5("hello"))
2311        let hash = fingerprint_bytes(b"hello");
2312        assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
2313    }
2314
2315    #[test]
2316    fn test_fingerprint_bytes_known_php_value() {
2317        // 对齐 PHP: md5("The quick brown fox jumps over the lazy dog")
2318        //   = 9e107d9d372bb6826bd81d3542a419d6
2319        // (注意:无结尾句点版本,对齐 RFC 1321 测试向量)
2320        let hash = fingerprint_bytes(b"The quick brown fox jumps over the lazy dog");
2321        assert_eq!(hash, "9e107d9d372bb6826bd81d3542a419d6");
2322    }
2323
2324    #[test]
2325    fn test_fingerprint_bytes_lowercase_hex() {
2326        // 验证输出为小写十六进制(对齐 PHP md5() 默认输出)
2327        let hash = fingerprint_bytes(b"test");
2328        assert!(
2329            hash.chars()
2330                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
2331            "MD5 hash should be lowercase hex: {hash}"
2332        );
2333    }
2334
2335    #[test]
2336    fn test_fingerprint_file_reads_content() {
2337        let dir = create_test_dir();
2338        let file_path = dir.path().join("content.txt");
2339        fs::write(&file_path, b"hello").unwrap();
2340
2341        let file_hash = fingerprint_file(&file_path).unwrap();
2342        let bytes_hash = fingerprint_bytes(b"hello");
2343        assert_eq!(file_hash, bytes_hash);
2344        assert_eq!(file_hash, "5d41402abc4b2a76b9719d911017c592");
2345    }
2346
2347    #[test]
2348    fn test_fingerprint_file_missing_returns_err() {
2349        let dir = create_test_dir();
2350        let missing = dir.path().join("nonexistent.txt");
2351        let result = fingerprint_file(&missing);
2352        assert!(result.is_err(), "Missing file should return Err");
2353    }
2354
2355    // ====================================================================
2356    // extract_version_hash 测试
2357    //         (对齐前端构建工具文件名 hash 解析)
2358    // ====================================================================
2359
2360    #[test]
2361    fn test_extract_version_hash_valid() {
2362        // 标准格式:style.<hash>.css
2363        let result = extract_version_hash("style.abc123def456.css");
2364        assert_eq!(
2365            result,
2366            Some(("style.css".to_string(), "abc123def456".to_string()))
2367        );
2368    }
2369
2370    #[test]
2371    fn test_extract_version_hash_path_with_dir() {
2372        // 带目录路径:js/app.<hash>.js
2373        let result = extract_version_hash("js/app.abc123def456.js");
2374        assert_eq!(
2375            result,
2376            Some(("js/app.js".to_string(), "abc123def456".to_string()))
2377        );
2378    }
2379
2380    #[test]
2381    fn test_extract_version_hash_multi_dot_stem() {
2382        // 多点文件名:foo.bar.<hash>.css
2383        let result = extract_version_hash("foo.bar.abc123def456.css");
2384        assert_eq!(
2385            result,
2386            Some(("foo.bar.css".to_string(), "abc123def456".to_string()))
2387        );
2388    }
2389
2390    #[test]
2391    fn test_extract_version_hash_min_8_chars() {
2392        // 恰好 8 位 hash(边界值)
2393        let result = extract_version_hash("style.abc12345.css");
2394        assert_eq!(
2395            result,
2396            Some(("style.css".to_string(), "abc12345".to_string()))
2397        );
2398    }
2399
2400    #[test]
2401    fn test_extract_version_hash_no_hash() {
2402        // 无 hash(无第二个点)
2403        let result = extract_version_hash("style.css");
2404        assert_eq!(result, None);
2405    }
2406
2407    #[test]
2408    fn test_extract_version_hash_short_hash() {
2409        // hash 不足 8 位 → None
2410        let result = extract_version_hash("style.abc123.css");
2411        assert_eq!(result, None);
2412    }
2413
2414    #[test]
2415    fn test_extract_version_hash_non_hex() {
2416        // hash 含非十六进制字符 → None
2417        let result = extract_version_hash("style.xyzghijk.css");
2418        assert_eq!(result, None);
2419    }
2420
2421    #[test]
2422    fn test_extract_version_hash_uppercase_hex() {
2423        // 大写十六进制应被接受(is_ascii_hexdigit 接受大小写)
2424        let result = extract_version_hash("style.ABCDEF12.css");
2425        assert_eq!(
2426            result,
2427            Some(("style.css".to_string(), "ABCDEF12".to_string()))
2428        );
2429    }
2430
2431    #[test]
2432    fn test_extract_version_hash_no_extension() {
2433        // 无扩展名 → None
2434        let result = extract_version_hash("noextension");
2435        assert_eq!(result, None);
2436    }
2437
2438    #[test]
2439    fn test_extract_version_hash_empty_extension() {
2440        // 末尾点 → 空扩展名 → None
2441        let result = extract_version_hash("style.abc123def456.");
2442        assert_eq!(result, None);
2443    }
2444
2445    // ====================================================================
2446    // serve_file_with_cache 测试
2447    // ====================================================================
2448
2449    #[tokio::test]
2450    async fn test_serve_file_with_cache_200_no_config() {
2451        // 无 Cache-Control 配置 → 不设置 Cache-Control 头
2452        let dir = create_test_dir();
2453        let file_path = dir.path().join("style.css");
2454        let headers = axum::http::HeaderMap::new();
2455
2456        let resp = serve_file_with_cache(&file_path, &headers, None).await;
2457        assert_eq!(resp.status(), StatusCode::OK);
2458
2459        let cc = resp.headers().get("cache-control");
2460        assert!(
2461            cc.is_none(),
2462            "Cache-Control should not be set without config"
2463        );
2464
2465        // ETag 仍应设置
2466        let etag = resp.headers().get("etag");
2467        assert!(etag.is_some(), "ETag should be set");
2468    }
2469
2470    #[tokio::test]
2471    async fn test_serve_file_with_cache_200_with_cache_control() {
2472        let dir = create_test_dir();
2473        let file_path = dir.path().join("style.css");
2474        let headers = axum::http::HeaderMap::new();
2475        let config = CacheControlConfig::new().with_public().with_max_age(3600);
2476
2477        let resp = serve_file_with_cache(&file_path, &headers, Some(&config)).await;
2478        assert_eq!(resp.status(), StatusCode::OK);
2479
2480        let cc = resp
2481            .headers()
2482            .get("cache-control")
2483            .unwrap()
2484            .to_str()
2485            .unwrap();
2486        assert_eq!(cc, "public, max-age=3600");
2487    }
2488
2489    #[tokio::test]
2490    async fn test_serve_file_with_cache_etag_header_set() {
2491        // 200 响应应设置 ETag 头
2492        let dir = create_test_dir();
2493        let file_path = dir.path().join("style.css");
2494        let headers = axum::http::HeaderMap::new();
2495
2496        let resp = serve_file_with_cache(&file_path, &headers, None).await;
2497        assert_eq!(resp.status(), StatusCode::OK);
2498
2499        let etag = resp.headers().get("etag").unwrap().to_str().unwrap();
2500        assert!(
2501            etag.starts_with("W/\""),
2502            "ETag should be weak format, got: {etag}"
2503        );
2504    }
2505
2506    #[tokio::test]
2507    async fn test_serve_file_with_cache_304_if_none_match_match() {
2508        // If-None-Match 匹配 ETag → 304
2509        let dir = create_test_dir();
2510        let file_path = dir.path().join("style.css");
2511
2512        // 第一次请求获取 ETag
2513        let headers1 = axum::http::HeaderMap::new();
2514        let resp1 = serve_file_with_cache(&file_path, &headers1, None).await;
2515        let etag = resp1
2516            .headers()
2517            .get("etag")
2518            .unwrap()
2519            .to_str()
2520            .unwrap()
2521            .to_string();
2522
2523        // 第二次请求带 If-None-Match: <etag>
2524        let mut headers2 = axum::http::HeaderMap::new();
2525        headers2.insert(
2526            axum::http::header::IF_NONE_MATCH,
2527            axum::http::HeaderValue::from_str(&etag).unwrap(),
2528        );
2529        let resp2 = serve_file_with_cache(&file_path, &headers2, None).await;
2530        assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
2531
2532        // 304 应包含 ETag
2533        let resp2_etag = resp2.headers().get("etag").unwrap().to_str().unwrap();
2534        assert_eq!(resp2_etag, etag);
2535
2536        // 304 应包含 Last-Modified
2537        assert!(
2538            resp2.headers().get("last-modified").is_some(),
2539            "304 should include Last-Modified"
2540        );
2541
2542        // 304 body 应为空
2543        let bytes = resp2.into_body().collect().await.unwrap().to_bytes();
2544        assert!(bytes.is_empty(), "304 body should be empty");
2545    }
2546
2547    #[tokio::test]
2548    async fn test_serve_file_with_cache_304_if_none_match_star() {
2549        // If-None-Match: * → 总是 304(对齐 HTTP/1.1)
2550        let dir = create_test_dir();
2551        let file_path = dir.path().join("style.css");
2552
2553        let mut headers = axum::http::HeaderMap::new();
2554        headers.insert(
2555            axum::http::header::IF_NONE_MATCH,
2556            axum::http::HeaderValue::from_static("*"),
2557        );
2558        let resp = serve_file_with_cache(&file_path, &headers, None).await;
2559        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
2560    }
2561
2562    #[tokio::test]
2563    async fn test_serve_file_with_cache_200_if_none_match_mismatch() {
2564        // If-None-Match 不匹配 → 200
2565        let dir = create_test_dir();
2566        let file_path = dir.path().join("style.css");
2567
2568        let mut headers = axum::http::HeaderMap::new();
2569        headers.insert(
2570            axum::http::header::IF_NONE_MATCH,
2571            axum::http::HeaderValue::from_static("W/\"0-0\""),
2572        );
2573        let resp = serve_file_with_cache(&file_path, &headers, None).await;
2574        assert_eq!(resp.status(), StatusCode::OK);
2575    }
2576
2577    #[tokio::test]
2578    async fn test_serve_file_with_cache_304_includes_cache_control() {
2579        // 304 响应应包含 Cache-Control(对齐 nginx 行为)
2580        let dir = create_test_dir();
2581        let file_path = dir.path().join("style.css");
2582
2583        // 先获取 ETag
2584        let headers1 = axum::http::HeaderMap::new();
2585        let config = CacheControlConfig::new().with_public().with_max_age(3600);
2586        let resp1 = serve_file_with_cache(&file_path, &headers1, Some(&config)).await;
2587        let etag = resp1
2588            .headers()
2589            .get("etag")
2590            .unwrap()
2591            .to_str()
2592            .unwrap()
2593            .to_string();
2594
2595        // 带 If-None-Match 请求
2596        let mut headers2 = axum::http::HeaderMap::new();
2597        headers2.insert(
2598            axum::http::header::IF_NONE_MATCH,
2599            axum::http::HeaderValue::from_str(&etag).unwrap(),
2600        );
2601        let resp2 = serve_file_with_cache(&file_path, &headers2, Some(&config)).await;
2602        assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
2603
2604        let cc = resp2
2605            .headers()
2606            .get("cache-control")
2607            .expect("304 should include Cache-Control")
2608            .to_str()
2609            .unwrap();
2610        assert_eq!(cc, "public, max-age=3600");
2611    }
2612
2613    #[tokio::test]
2614    async fn test_serve_file_with_cache_304_if_modified_since_match() {
2615        // If-Modified-Since 匹配 → 304(对齐 PHP think-worker)
2616        let dir = create_test_dir();
2617        let file_path = dir.path().join("style.css");
2618
2619        // 先获取 Last-Modified
2620        let headers1 = axum::http::HeaderMap::new();
2621        let resp1 = serve_file_with_cache(&file_path, &headers1, None).await;
2622        let last_modified = resp1
2623            .headers()
2624            .get("last-modified")
2625            .unwrap()
2626            .to_str()
2627            .unwrap()
2628            .to_string();
2629
2630        // 带 If-Modified-Since 请求
2631        let mut headers2 = axum::http::HeaderMap::new();
2632        headers2.insert(
2633            axum::http::header::IF_MODIFIED_SINCE,
2634            axum::http::HeaderValue::from_str(&last_modified).unwrap(),
2635        );
2636        let resp2 = serve_file_with_cache(&file_path, &headers2, None).await;
2637        assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
2638    }
2639
2640    #[tokio::test]
2641    async fn test_serve_file_with_cache_if_none_match_takes_priority() {
2642        // 同时存在 If-None-Match 和 If-Modified-Since,
2643        // If-None-Match 优先(对齐 HTTP/1.1 优先级)
2644        let dir = create_test_dir();
2645        let file_path = dir.path().join("style.css");
2646
2647        // 获取正确的 Last-Modified
2648        let headers1 = axum::http::HeaderMap::new();
2649        let resp1 = serve_file_with_cache(&file_path, &headers1, None).await;
2650        let last_modified = resp1
2651            .headers()
2652            .get("last-modified")
2653            .unwrap()
2654            .to_str()
2655            .unwrap()
2656            .to_string();
2657
2658        // If-None-Match 不匹配,If-Modified-Since 匹配
2659        // 预期:If-None-Match 优先级更高,不匹配 → 200
2660        let mut headers2 = axum::http::HeaderMap::new();
2661        headers2.insert(
2662            axum::http::header::IF_NONE_MATCH,
2663            axum::http::HeaderValue::from_static("W/\"0-0\""),
2664        );
2665        headers2.insert(
2666            axum::http::header::IF_MODIFIED_SINCE,
2667            axum::http::HeaderValue::from_str(&last_modified).unwrap(),
2668        );
2669        let resp2 = serve_file_with_cache(&file_path, &headers2, None).await;
2670        assert_eq!(
2671            resp2.status(),
2672            StatusCode::OK,
2673            "If-None-Match should take priority over If-Modified-Since"
2674        );
2675    }
2676
2677    #[tokio::test]
2678    async fn test_serve_file_with_cache_404() {
2679        let dir = create_test_dir();
2680        let file_path = dir.path().join("nonexistent.txt");
2681        let headers = axum::http::HeaderMap::new();
2682
2683        let resp = serve_file_with_cache(&file_path, &headers, None).await;
2684        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2685    }
2686
2687    #[tokio::test]
2688    async fn test_serve_file_with_cache_range_206_includes_etag() {
2689        // 206 响应应包含 ETag 和 Cache-Control
2690        let dir = create_test_dir();
2691        let file_path = dir.path().join("data.bin");
2692        fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); // 20 字节
2693
2694        let mut headers = axum::http::HeaderMap::new();
2695        headers.insert(
2696            axum::http::header::RANGE,
2697            axum::http::HeaderValue::from_static("bytes=5-9"),
2698        );
2699
2700        let config = CacheControlConfig::new().with_max_age(3600);
2701        let resp = serve_file_with_cache(&file_path, &headers, Some(&config)).await;
2702        assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
2703
2704        let cr = resp
2705            .headers()
2706            .get("content-range")
2707            .unwrap()
2708            .to_str()
2709            .unwrap();
2710        assert_eq!(cr, "bytes 5-9/20");
2711
2712        // ETag 应设置
2713        assert!(
2714            resp.headers().get("etag").is_some(),
2715            "206 should include ETag"
2716        );
2717
2718        // Cache-Control 应设置
2719        let cc = resp
2720            .headers()
2721            .get("cache-control")
2722            .unwrap()
2723            .to_str()
2724            .unwrap();
2725        assert_eq!(cc, "max-age=3600");
2726
2727        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2728        assert_eq!(&bytes[..], b"56789");
2729    }
2730
2731    #[tokio::test]
2732    async fn test_serve_file_with_cache_range_416() {
2733        // Range 超出文件大小 → 416
2734        let dir = create_test_dir();
2735        let file_path = dir.path().join("data.bin");
2736        fs::write(&file_path, b"0123456789").unwrap(); // 10 字节
2737
2738        let mut headers = axum::http::HeaderMap::new();
2739        headers.insert(
2740            axum::http::header::RANGE,
2741            axum::http::HeaderValue::from_static("bytes=100-200"),
2742        );
2743
2744        let resp = serve_file_with_cache(&file_path, &headers, None).await;
2745        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
2746
2747        let cr = resp
2748            .headers()
2749            .get("content-range")
2750            .unwrap()
2751            .to_str()
2752            .unwrap();
2753        assert_eq!(cr, "bytes */10");
2754    }
2755
2756    #[tokio::test]
2757    async fn test_serve_file_with_cache_unknown_mime_content_disposition() {
2758        // 未知 MIME 类型应设置 Content-Disposition(对齐 PHP think-worker)
2759        let dir = create_test_dir();
2760        let file_path = dir.path().join("unknown.xyzunknown");
2761        fs::write(&file_path, b"unknown content").unwrap();
2762
2763        let headers = axum::http::HeaderMap::new();
2764        let resp = serve_file_with_cache(&file_path, &headers, None).await;
2765        assert_eq!(resp.status(), StatusCode::OK);
2766
2767        let cd = resp
2768            .headers()
2769            .get("content-disposition")
2770            .expect("Content-Disposition should be set for unknown MIME");
2771        let cd_str = cd.to_str().unwrap();
2772        assert!(
2773            cd_str.contains("unknown.xyzunknown"),
2774            "Content-Disposition should contain filename, got: {cd_str}"
2775        );
2776    }
2777
2778    // ====================================================================
2779    // R5 PHP/Rust 行为对比测试
2780    // ====================================================================
2781
2782    #[test]
2783    fn test_r5_php_no_etag_but_rust_extends_with_etag() {
2784        // R5 PHP 行为对比:
2785        // - PHP think-worker sendFile 不生成 ETag(仅 Last-Modified + If-Modified-Since)
2786        // - Rust 在 PHP 基础上扩展:增加 ETag + If-None-Match(对齐 nginx 默认行为)
2787        //
2788        // 验证:Rust compute_etag 生成 nginx 风格的 weak ETag
2789        let dir = create_test_dir();
2790        let file_path = dir.path().join("style.css");
2791        let metadata = std::fs::metadata(&file_path).unwrap();
2792
2793        let etag = compute_etag(&metadata);
2794        assert!(etag.is_some(), "Rust should generate ETag (PHP doesn't)");
2795
2796        // 验证 nginx 格式:W/"<mtime>-<size>"
2797        let etag_str = etag.unwrap();
2798        assert!(
2799            etag_str.starts_with("W/\"") && etag_str.ends_with('"'),
2800            "ETag should be nginx weak format"
2801        );
2802    }
2803
2804    #[test]
2805    fn test_r5_php_md5_alignment() {
2806        // R5 PHP 行为对比:
2807        // PHP md5() 输出 32 位小写十六进制字符串
2808        // Rust fingerprint_bytes 应与 PHP md5() 完全一致
2809        //
2810        // PHP 验证脚本:
2811        //   php -r 'echo md5("hello");'
2812        //   输出: 5d41402abc4b2a76b9719d911017c592
2813        let rust_hash = fingerprint_bytes(b"hello");
2814        let php_hash = "5d41402abc4b2a76b9719d911017c592";
2815        assert_eq!(rust_hash, php_hash, "Rust MD5 should match PHP md5()");
2816    }
2817
2818    #[test]
2819    fn test_r5_nginx_etag_format_alignment() {
2820        // R5 nginx 行为对比:
2821        // nginx 默认 ETag 格式:W/"<mtime>-<size>"
2822        //   - mtime: 文件修改时间的 Unix 秒
2823        //   - size: 文件大小(字节)
2824        //
2825        // 验证:Rust compute_etag 输出格式与 nginx 完全一致
2826        let dir = create_test_dir();
2827        let file_path = dir.path().join("data.bin");
2828        fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); // 20 字节
2829        let metadata = std::fs::metadata(&file_path).unwrap();
2830
2831        let mtime = metadata
2832            .modified()
2833            .unwrap()
2834            .duration_since(std::time::UNIX_EPOCH)
2835            .unwrap()
2836            .as_secs();
2837        let size = metadata.len();
2838
2839        let expected_etag = format!("W/\"{}-{}\"", mtime, size);
2840        let actual_etag = compute_etag(&metadata).unwrap();
2841        assert_eq!(
2842            actual_etag, expected_etag,
2843            "Rust ETag should match nginx format exactly"
2844        );
2845    }
2846}