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