Skip to main content

sz_rust_core/
debug_page.rs

1//! Whoops-style HTML 调试页面 — 对齐 PHP `whoops` 异常展示
2//!
3//! ## PHP 对齐
4//!
5//! ThinkPHP 6 默认集成 `whoops` 库,在开发环境渲染交互式错误页面:
6//! - 顶部红色标题栏(错误类型 + 消息)
7//! - 文件:行号(灰色路径,可点击打开 IDE)
8//! - 堆栈列表(可折叠,每帧显示文件:行号 + 函数名 + 源码片段)
9//! - 请求信息(method / uri / headers / query / body)
10//! - 环境信息(Rust 版本 / 进程 PID / 工作目录)
11//!
12//! 生产环境关闭调试页,返回简洁 JSON 或静态 HTML,避免泄露堆栈。
13//!
14//! ## 安全约束
15//!
16//! - 所有用户输入(错误消息、文件路径、请求头值)必须 HTML 转义,防止 XSS
17//! - 源码片段从文件读取,限制最多 21 行(前后 10 行 + 错误行),避免读取大文件
18//! - 调试页只在 `debug_mode = true` 时渲染,生产环境强制返回简洁页
19
20use axum::http::{HeaderMap, StatusCode};
21use axum::response::{IntoResponse, Response};
22use std::collections::HashMap;
23
24// ============================================================================
25// 调试错误信息
26// ============================================================================
27
28/// 单个堆栈帧
29///
30/// 对齐 PHP `whoops\Frame`:每帧包含文件、行号、函数名、源码片段。
31#[derive(Debug, Clone)]
32pub struct StackFrame {
33    /// 文件路径(绝对路径)
34    pub file: String,
35    /// 行号(1-based)
36    pub line: usize,
37    /// 函数名(如 `app::handler::create_user`)
38    pub function: String,
39    /// 源码片段(行号 → 源码行),由 [`DebugError::with_source_snippet`] 填充
40    pub source_lines: Vec<(usize, String)>,
41}
42
43impl StackFrame {
44    /// 创建新的堆栈帧(不含源码片段)
45    ///
46    /// # 参数
47    ///
48    /// - `file`:文件路径
49    /// - `line`:行号
50    /// - `function`:函数名
51    pub fn new(file: impl Into<String>, line: usize, function: impl Into<String>) -> Self {
52        Self {
53            file: file.into(),
54            line,
55            function: function.into(),
56            source_lines: Vec::new(),
57        }
58    }
59
60    /// 从文件读取源码片段(错误行前后各 `context` 行,最多 `context * 2 + 1` 行)
61    ///
62    /// 读取失败(文件不存在/IO 错误)时静默忽略,`source_lines` 保持为空。
63    ///
64    /// # 参数
65    ///
66    /// - `context`:错误行前后的上下文行数(建议 10)
67    pub fn load_source_snippet(&mut self, context: usize) {
68        if self.line == 0 || self.file.is_empty() {
69            return;
70        }
71
72        let content = match std::fs::read_to_string(&self.file) {
73            Ok(c) => c,
74            Err(_) => return, // 文件不可读(如内置函数、动态生成代码)
75        };
76
77        let lines: Vec<&str> = content.lines().collect();
78        let start = self.line.saturating_sub(context).max(1);
79        let end = (self.line + context).min(lines.len());
80
81        for (idx, line_content) in lines.iter().enumerate() {
82            let line_num = idx + 1;
83            if line_num >= start && line_num <= end {
84                self.source_lines.push((line_num, line_content.to_string()));
85            }
86        }
87    }
88}
89
90/// 调试错误信息
91///
92/// 包含完整的错误上下文:消息、类型、堆栈、请求信息。
93/// 由 [`render_debug_html`] 渲染为 Whoops-style HTML。
94#[derive(Debug, Clone)]
95pub struct DebugError {
96    /// 错误类型名(如 `"panic"` / `"IoError"` / `"SqlError"`)
97    pub error_type: String,
98    /// 错误消息
99    pub message: String,
100    /// 错误发生的文件
101    pub file: String,
102    /// 错误发生的行号(1-based)
103    pub line: usize,
104    /// 堆栈帧列表(按调用顺序:最新帧在前)
105    pub stack: Vec<StackFrame>,
106    /// 请求方法
107    pub method: String,
108    /// 请求 URI
109    pub uri: String,
110    /// 请求头(已脱敏)
111    pub headers: HashMap<String, String>,
112    /// 请求查询参数
113    pub query_params: HashMap<String, String>,
114}
115
116impl DebugError {
117    /// 创建新的调试错误
118    pub fn new(
119        error_type: impl Into<String>,
120        message: impl Into<String>,
121        file: impl Into<String>,
122        line: usize,
123    ) -> Self {
124        Self {
125            error_type: error_type.into(),
126            message: message.into(),
127            file: file.into(),
128            line,
129            stack: Vec::new(),
130            method: String::new(),
131            uri: String::new(),
132            headers: HashMap::new(),
133            query_params: HashMap::new(),
134        }
135    }
136
137    /// 添加堆栈帧
138    pub fn with_frame(mut self, frame: StackFrame) -> Self {
139        self.stack.push(frame);
140        self
141    }
142
143    /// 设置请求信息
144    pub fn with_request(
145        mut self,
146        method: impl Into<String>,
147        uri: impl Into<String>,
148        headers: HeaderMap,
149        query_params: HashMap<String, String>,
150    ) -> Self {
151        self.method = method.into();
152        self.uri = uri.into();
153        self.headers = sanitize_headers(&headers);
154        self.query_params = query_params;
155        self
156    }
157
158    /// 为所有堆栈帧加载源码片段(包含错误位置本身)
159    ///
160    /// # 参数
161    ///
162    /// - `context`:错误行前后的上下文行数(建议 10)
163    pub fn with_source_snippet(mut self, context: usize) -> Self {
164        // 为错误位置加载源码
165        if !self.file.is_empty() && self.line > 0 {
166            let mut main_frame = StackFrame::new(self.file.clone(), self.line, "<main>");
167            main_frame.load_source_snippet(context);
168            // 主错误信息也存为虚拟帧
169            if !main_frame.source_lines.is_empty() {
170                self.stack.insert(0, main_frame);
171            }
172        }
173
174        // 为所有堆栈帧加载源码
175        for frame in &mut self.stack {
176            if frame.source_lines.is_empty() {
177                frame.load_source_snippet(context);
178            }
179        }
180        self
181    }
182}
183
184// ============================================================================
185// 调试页配置
186// ============================================================================
187
188/// 调试页配置
189///
190/// 控制调试页的渲染行为:是否启用、源码上下文行数、是否显示堆栈。
191#[derive(Debug, Clone)]
192pub struct DebugPageConfig {
193    /// 是否启用调试模式(true 渲染 Whoops-style HTML,false 返回简洁错误页)
194    pub debug_mode: bool,
195    /// 源码上下文行数(错误行前后各 N 行)
196    pub source_context: usize,
197    /// 是否显示堆栈
198    pub show_stack: bool,
199    /// 是否显示请求信息
200    pub show_request: bool,
201    /// 是否显示环境信息
202    pub show_environment: bool,
203}
204
205impl Default for DebugPageConfig {
206    fn default() -> Self {
207        Self {
208            debug_mode: false,
209            source_context: 10,
210            show_stack: true,
211            show_request: true,
212            show_environment: true,
213        }
214    }
215}
216
217impl DebugPageConfig {
218    /// 创建开发环境配置(启用所有调试信息)
219    pub fn development() -> Self {
220        Self {
221            debug_mode: true,
222            source_context: 10,
223            show_stack: true,
224            show_request: true,
225            show_environment: true,
226        }
227    }
228
229    /// 创建生产环境配置(关闭所有调试信息,仅显示简洁错误页)
230    pub fn production() -> Self {
231        Self {
232            debug_mode: false,
233            source_context: 0,
234            show_stack: false,
235            show_request: false,
236            show_environment: false,
237        }
238    }
239}
240
241// ============================================================================
242// HTML 渲染
243// ============================================================================
244
245/// 渲染 Whoops-style HTML 调试页
246///
247/// # 安全约束
248///
249/// - 所有用户输入经 `html_escape` 转义,防 XSS
250/// - HTML 内联 CSS(无外部依赖)
251/// - 源码片段限制最多 `context * 2 + 1` 行,避免读取大文件
252pub fn render_debug_html(error: &DebugError, config: &DebugPageConfig) -> String {
253    let title = html_escape(&format!("{}: {}", error.error_type, error.message));
254    let file_display = html_escape(&error.file);
255    let method_display = html_escape(&error.method);
256    let uri_display = html_escape(&error.uri);
257    // 显式绑定避免与内置 `line!` 宏冲突
258    let error_line = error.line;
259
260    let mut html = format!(
261        r#"<!DOCTYPE html>
262<html lang="zh-CN">
263<head>
264<meta charset="UTF-8">
265<title>{title}</title>
266<style>
267* {{ margin: 0; padding: 0; box-sizing: border-box; }}
268body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #333; }}
269.header {{ background: #d23f31; color: #fff; padding: 24px 32px; }}
270.header h1 {{ font-size: 22px; margin-bottom: 8px; word-break: break-all; }}
271.header .location {{ color: rgba(255,255,255,0.85); font-size: 13px; font-family: "Fira Code", monospace; }}
272.container {{ max-width: 1200px; margin: 24px auto; padding: 0 24px; }}
273.section {{ background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; overflow: hidden; }}
274.section-title {{ background: #f5f5f5; padding: 12px 20px; border-bottom: 1px solid #e0e0e0; font-size: 14px; font-weight: 600; color: #555; }}
275.section-body {{ padding: 16px 20px; }}
276.stack-frame {{ border-bottom: 1px solid #eee; padding: 12px 0; }}
277.stack-frame:last-child {{ border-bottom: none; }}
278.frame-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }}
279.frame-function {{ color: #d23f31; font-family: "Fira Code", monospace; font-size: 13px; font-weight: 600; }}
280.frame-location {{ color: #888; font-family: "Fira Code", monospace; font-size: 12px; }}
281.source-list {{ background: #1e1e1e; border-radius: 4px; padding: 12px; overflow-x: auto; font-family: "Fira Code", monospace; font-size: 13px; }}
282.source-line {{ display: flex; color: #d4d4d4; }}
283.source-line.error {{ background: rgba(210,63,49,0.2); }}
284.line-num {{ color: #858585; min-width: 50px; text-align: right; padding-right: 16px; user-select: none; }}
285.line-content {{ white-space: pre; }}
286.request-table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
287.request-table th, .request-table td {{ text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; }}
288.request-table th {{ background: #fafafa; width: 200px; color: #555; font-weight: 600; }}
289.request-table td {{ font-family: "Fira Code", monospace; word-break: break-all; }}
290.env-grid {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; font-size: 13px; }}
291.env-item {{ padding: 8px 12px; background: #fafafa; border-radius: 4px; }}
292.env-item strong {{ color: #555; display: inline-block; min-width: 120px; }}
293.footer {{ text-align: center; padding: 24px; color: #999; font-size: 12px; }}
294</style>
295</head>
296<body>
297<div class="header">
298  <h1>{title}</h1>
299  <div class="location">{file_display}:{error_line}</div>
300</div>
301<div class="container">
302"#
303    );
304
305    // 堆栈
306    if config.show_stack && !error.stack.is_empty() {
307        html.push_str("<div class=\"section\">\n");
308        html.push_str("  <div class=\"section-title\">Stack frames (");
309        html.push_str(&error.stack.len().to_string());
310        html.push_str(")</div>\n");
311        html.push_str("  <div class=\"section-body\">\n");
312        for frame in &error.stack {
313            html.push_str(&render_stack_frame_html(frame));
314        }
315        html.push_str("  </div>\n</div>\n");
316    }
317
318    // 请求信息
319    if config.show_request && !error.method.is_empty() {
320        html.push_str("<div class=\"section\">\n");
321        html.push_str("  <div class=\"section-title\">Request</div>\n");
322        html.push_str("  <div class=\"section-body\">\n");
323        html.push_str("    <table class=\"request-table\">\n");
324        html.push_str(&format!(
325            "      <tr><th>Method</th><td>{}</td></tr>\n",
326            method_display
327        ));
328        html.push_str(&format!(
329            "      <tr><th>URI</th><td>{}</td></tr>\n",
330            uri_display
331        ));
332        for (key, value) in &error.headers {
333            html.push_str(&format!(
334                "      <tr><th>{}</th><td>{}</td></tr>\n",
335                html_escape(key),
336                html_escape(value)
337            ));
338        }
339        for (key, value) in &error.query_params {
340            html.push_str(&format!(
341                "      <tr><th>Query: {}</th><td>{}</td></tr>\n",
342                html_escape(key),
343                html_escape(value)
344            ));
345        }
346        html.push_str("    </table>\n  </div>\n</div>\n");
347    }
348
349    // 环境信息
350    if config.show_environment {
351        html.push_str("<div class=\"section\">\n");
352        html.push_str("  <div class=\"section-title\">Environment</div>\n");
353        html.push_str("  <div class=\"section-body\">\n");
354        html.push_str("    <div class=\"env-grid\">\n");
355        html.push_str(&format!(
356            "      <div class=\"env-item\"><strong>Rust version</strong> {}</div>\n",
357            env!("CARGO_PKG_VERSION")
358        ));
359        html.push_str(&format!(
360            "      <div class=\"env-item\"><strong>PID</strong> {}</div>\n",
361            std::process::id()
362        ));
363        if let Ok(cwd) = std::env::current_dir() {
364            html.push_str(&format!(
365                "      <div class=\"env-item\"><strong>Working dir</strong> {}</div>\n",
366                html_escape(&cwd.display().to_string())
367            ));
368        }
369        let now = chrono::Local::now();
370        html.push_str(&format!(
371            "      <div class=\"env-item\"><strong>Time</strong> {}</div>\n",
372            html_escape(&now.format("%Y-%m-%d %H:%M:%S").to_string())
373        ));
374        html.push_str("    </div>\n  </div>\n</div>\n");
375    }
376
377    html.push_str("</div>\n");
378    html.push_str(
379        "<div class=\"footer\">SZ-Rust Whoops-style Debugger — debug mode enabled</div>\n",
380    );
381    html.push_str("</body>\n</html>\n");
382
383    html
384}
385
386/// 渲染单个堆栈帧的 HTML
387fn render_stack_frame_html(frame: &StackFrame) -> String {
388    let function = html_escape(&frame.function);
389    let location = html_escape(&format!("{}:{}", frame.file, frame.line));
390
391    let mut html = format!(
392        r#"    <div class="stack-frame">
393      <div class="frame-header">
394        <span class="frame-function">{function}</span>
395        <span class="frame-location">{location}</span>
396      </div>
397"#
398    );
399
400    if !frame.source_lines.is_empty() {
401        html.push_str("      <div class=\"source-list\">\n");
402        for (line_num, content) in &frame.source_lines {
403            let is_error_line = *line_num == frame.line;
404            let line_class = if is_error_line { " error" } else { "" };
405            html.push_str(&format!(
406                "        <div class=\"source-line{}\"><span class=\"line-num\">{}</span><span class=\"line-content\">{}</span></div>\n",
407                line_class,
408                line_num,
409                html_escape(content)
410            ));
411        }
412        html.push_str("      </div>\n");
413    }
414
415    html.push_str("    </div>\n");
416    html
417}
418
419/// 渲染生产环境简洁错误页(不泄露堆栈)
420pub fn render_production_html(status: StatusCode, message: &str) -> String {
421    let status_code = status.as_u16();
422    let status_text = html_escape(status.canonical_reason().unwrap_or("Error"));
423    let msg = html_escape(message);
424
425    format!(
426        r#"<!DOCTYPE html>
427<html lang="zh-CN">
428<head>
429<meta charset="UTF-8">
430<title>{status_code} {status_text}</title>
431<style>
432body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }}
433.error-box {{ background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); padding: 48px 64px; text-align: center; max-width: 480px; }}
434.error-code {{ font-size: 72px; font-weight: 700; color: #d23f31; line-height: 1; margin-bottom: 16px; }}
435.error-title {{ font-size: 20px; color: #555; margin-bottom: 8px; }}
436.error-message {{ font-size: 14px; color: #999; }}
437</style>
438</head>
439<body>
440<div class="error-box">
441  <div class="error-code">{status_code}</div>
442  <div class="error-title">{status_text}</div>
443  <div class="error-message">{msg}</div>
444</div>
445</body>
446</html>
447"#
448    )
449}
450
451// ============================================================================
452// 响应构建
453// ============================================================================
454
455/// 根据调试配置构建错误响应
456///
457/// - `debug_mode = true`:返回 Whoops-style HTML(含堆栈/源码/请求信息)
458/// - `debug_mode = false`:返回简洁 HTML(仅状态码 + 标准描述,不泄露堆栈和原始消息)
459///
460/// # 安全约束
461///
462/// 生产模式下绝不显示 `error.message`(可能含敏感信息如 SQL/密码),
463/// 仅显示状态码的标准描述(如 `Internal Server Error`)。
464pub fn debug_error_response(
465    status: StatusCode,
466    error: &DebugError,
467    config: &DebugPageConfig,
468) -> Response {
469    let html = if config.debug_mode {
470        render_debug_html(error, config)
471    } else {
472        // 生产模式:使用状态码的标准描述,不泄露原始错误消息
473        let safe_message = status.canonical_reason().unwrap_or("Error");
474        render_production_html(status, safe_message)
475    };
476
477    (
478        status,
479        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
480        html,
481    )
482        .into_response()
483}
484
485/// 从 panic 信息构建调试错误
486///
487/// 用于 `std::panic::set_hook` 捕获 panic 并渲染调试页。
488///
489/// 注:`PanicHookInfo` 自 Rust 1.81.0 起稳定,本框架 MSRV 已提升至 1.81.0+。
490pub fn from_panic(panic_info: &std::panic::PanicHookInfo<'_>) -> DebugError {
491    let message = panic_info
492        .payload()
493        .downcast_ref::<&str>()
494        .map(|s| s.to_string())
495        .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
496        .unwrap_or_else(|| "<unknown panic payload>".to_string());
497
498    let (file, line) = panic_info
499        .location()
500        .map(|loc| (loc.file().to_string(), loc.line() as usize))
501        .unwrap_or_else(|| ("<unknown>".to_string(), 0));
502
503    DebugError::new("panic", message, file, line)
504}
505
506// ============================================================================
507// 辅助函数
508// ============================================================================
509
510/// HTML 转义(防 XSS)
511///
512/// 转义字符:`&` → `&amp;`、`<` → `&lt;`、`>` → `&gt;`、`"` → `&quot;`、`'` → `&#39;`
513fn html_escape(s: &str) -> String {
514    let mut escaped = String::with_capacity(s.len());
515    for ch in s.chars() {
516        match ch {
517            '&' => escaped.push_str("&amp;"),
518            '<' => escaped.push_str("&lt;"),
519            '>' => escaped.push_str("&gt;"),
520            '"' => escaped.push_str("&quot;"),
521            '\'' => escaped.push_str("&#39;"),
522            _ => escaped.push(ch),
523        }
524    }
525    escaped
526}
527
528/// 脱敏请求头(移除敏感字段值,仅保留键名)
529///
530/// 对齐 PHP `whoops` 的 `RequestDataFormatter`:Authorization / Cookie / Set-Cookie 等
531/// 敏感头仅显示 `<redacted>`。
532fn sanitize_headers(headers: &HeaderMap) -> HashMap<String, String> {
533    const SENSITIVE_HEADERS: &[&str] = &[
534        "authorization",
535        "cookie",
536        "set-cookie",
537        "x-api-key",
538        "x-auth-token",
539    ];
540
541    let mut sanitized = HashMap::new();
542    for (name, value) in headers.iter() {
543        let name_str = name.as_str().to_lowercase();
544        let value_str = if SENSITIVE_HEADERS.contains(&name_str.as_str()) {
545            "<redacted>".to_string()
546        } else {
547            value.to_str().unwrap_or("<binary>").to_string()
548        };
549        sanitized.insert(name_str, value_str);
550    }
551    sanitized
552}
553
554// ============================================================================
555// 测试
556// ============================================================================
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use axum::http::{HeaderValue, Method};
562
563    // --------------------------------------------------------------------
564    // html_escape
565    // --------------------------------------------------------------------
566
567    #[test]
568    fn test_html_escape_basic() {
569        assert_eq!(html_escape("hello"), "hello");
570        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
571        assert_eq!(html_escape("\"quote\""), "&quot;quote&quot;");
572        assert_eq!(html_escape("'apos'"), "&#39;apos&#39;");
573        assert_eq!(html_escape("a & b"), "a &amp; b");
574    }
575
576    #[test]
577    fn test_html_escape_empty() {
578        assert_eq!(html_escape(""), "");
579    }
580
581    #[test]
582    fn test_html_escape_xss_payload() {
583        let payload = "<script>alert('XSS')</script>";
584        let escaped = html_escape(payload);
585        assert!(!escaped.contains('<'));
586        assert!(!escaped.contains('>'));
587        assert!(escaped.contains("&lt;script&gt;"));
588    }
589
590    // --------------------------------------------------------------------
591    // StackFrame::load_source_snippet
592    // --------------------------------------------------------------------
593
594    #[test]
595    fn test_stack_frame_load_source_snippet() {
596        let temp = tempfile::tempdir().unwrap();
597        let file_path = temp.path().join("test.rs");
598        std::fs::write(
599            &file_path,
600            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\n",
601        )
602        .unwrap();
603
604        let mut frame = StackFrame::new(file_path.to_str().unwrap(), 5, "test_fn");
605        frame.load_source_snippet(2);
606
607        // 应该包含 line 3-7(5-2=3, 5+2=7)
608        assert_eq!(frame.source_lines.len(), 5);
609        assert_eq!(frame.source_lines[0].0, 3);
610        assert_eq!(frame.source_lines[0].1, "line3");
611        assert_eq!(frame.source_lines[2].0, 5);
612        assert_eq!(frame.source_lines[2].1, "line5");
613        assert_eq!(frame.source_lines[4].0, 7);
614        assert_eq!(frame.source_lines[4].1, "line7");
615    }
616
617    #[test]
618    fn test_stack_frame_load_source_snippet_at_file_start() {
619        let temp = tempfile::tempdir().unwrap();
620        let file_path = temp.path().join("start.rs");
621        std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
622
623        let mut frame = StackFrame::new(file_path.to_str().unwrap(), 1, "first");
624        frame.load_source_snippet(10);
625
626        // line=1, context=10, 但文件只有 3 行
627        assert_eq!(frame.source_lines.len(), 3);
628        assert_eq!(frame.source_lines[0].0, 1);
629    }
630
631    #[test]
632    fn test_stack_frame_load_source_snippet_nonexistent_file() {
633        let mut frame = StackFrame::new("/nonexistent/file.rs", 10, "missing");
634        frame.load_source_snippet(5);
635        assert!(frame.source_lines.is_empty());
636    }
637
638    #[test]
639    fn test_stack_frame_load_source_snippet_zero_line() {
640        let mut frame = StackFrame::new("test.rs", 0, "unknown");
641        frame.load_source_snippet(5);
642        assert!(frame.source_lines.is_empty());
643    }
644
645    // --------------------------------------------------------------------
646    // DebugPageConfig
647    // --------------------------------------------------------------------
648
649    #[test]
650    fn test_debug_page_config_default() {
651        let config = DebugPageConfig::default();
652        assert!(!config.debug_mode);
653        assert_eq!(config.source_context, 10);
654    }
655
656    #[test]
657    fn test_debug_page_config_development() {
658        let config = DebugPageConfig::development();
659        assert!(config.debug_mode);
660        assert!(config.show_stack);
661        assert!(config.show_request);
662        assert!(config.show_environment);
663    }
664
665    #[test]
666    fn test_debug_page_config_production() {
667        let config = DebugPageConfig::production();
668        assert!(!config.debug_mode);
669        assert!(!config.show_stack);
670        assert_eq!(config.source_context, 0);
671    }
672
673    // --------------------------------------------------------------------
674    // render_debug_html
675    // --------------------------------------------------------------------
676
677    #[test]
678    fn test_render_debug_html_contains_error_info() {
679        let error = DebugError::new("TestError", "test message", "test.rs", 42);
680        let config = DebugPageConfig::development();
681        let html = render_debug_html(&error, &config);
682
683        assert!(html.contains("TestError"));
684        assert!(html.contains("test message"));
685        assert!(html.contains("test.rs:42"));
686        assert!(html.contains("<!DOCTYPE html>"));
687    }
688
689    #[test]
690    fn test_render_debug_html_escapes_xss() {
691        let error = DebugError::new(
692            "<script>alert('xss')</script>",
693            "<img src=x onerror=alert(1)>",
694            "test.rs",
695            1,
696        );
697        let config = DebugPageConfig::development();
698        let html = render_debug_html(&error, &config);
699
700        // 原始 <script> 不应出现
701        assert!(!html.contains("<script>alert"));
702        // 转义后应出现
703        assert!(html.contains("&lt;script&gt;"));
704        assert!(html.contains("&lt;img"));
705    }
706
707    #[test]
708    fn test_render_debug_html_shows_stack() {
709        let error = DebugError::new("Err", "msg", "test.rs", 1)
710            .with_frame(StackFrame::new("frame1.rs", 10, "func1"))
711            .with_frame(StackFrame::new("frame2.rs", 20, "func2"));
712        let config = DebugPageConfig::development();
713        let html = render_debug_html(&error, &config);
714
715        assert!(html.contains("func1"));
716        assert!(html.contains("func2"));
717        assert!(html.contains("frame1.rs:10"));
718        assert!(html.contains("frame2.rs:20"));
719        assert!(html.contains("Stack frames (2)"));
720    }
721
722    #[test]
723    fn test_render_debug_html_hides_stack_when_disabled() {
724        let error = DebugError::new("Err", "msg", "test.rs", 1).with_frame(StackFrame::new(
725            "frame1.rs",
726            10,
727            "func1",
728        ));
729        let mut config = DebugPageConfig::development();
730        config.show_stack = false;
731        let html = render_debug_html(&error, &config);
732
733        assert!(!html.contains("func1"));
734        assert!(!html.contains("Stack frames"));
735    }
736
737    #[test]
738    fn test_render_debug_html_shows_request_info() {
739        let mut headers = HeaderMap::new();
740        headers.insert("x-custom", HeaderValue::from_static("value1"));
741        let mut query = HashMap::new();
742        query.insert("id".to_string(), "123".to_string());
743
744        let error = DebugError::new("Err", "msg", "test.rs", 1).with_request(
745            Method::GET.as_str(),
746            "/api/users",
747            headers,
748            query,
749        );
750        let config = DebugPageConfig::development();
751        let html = render_debug_html(&error, &config);
752
753        assert!(html.contains("GET"));
754        assert!(html.contains("/api/users"));
755        assert!(html.contains("x-custom"));
756        assert!(html.contains("value1"));
757        assert!(html.contains("Query: id"));
758        assert!(html.contains("123"));
759    }
760
761    #[test]
762    fn test_render_debug_html_redacts_sensitive_headers() {
763        let mut headers = HeaderMap::new();
764        headers.insert(
765            "authorization",
766            HeaderValue::from_static("Bearer secret123"),
767        );
768        headers.insert("cookie", HeaderValue::from_static("session=abc"));
769
770        let error = DebugError::new("Err", "msg", "test.rs", 1).with_request(
771            "POST",
772            "/api",
773            headers,
774            HashMap::new(),
775        );
776        let config = DebugPageConfig::development();
777        let html = render_debug_html(&error, &config);
778
779        // 敏感值不应出现
780        assert!(!html.contains("secret123"));
781        assert!(!html.contains("session=abc"));
782        // 应显示 <redacted>
783        assert!(html.contains("&lt;redacted&gt;"));
784    }
785
786    #[test]
787    fn test_render_debug_html_shows_environment() {
788        let error = DebugError::new("Err", "msg", "test.rs", 1);
789        let config = DebugPageConfig::development();
790        let html = render_debug_html(&error, &config);
791
792        assert!(html.contains("PID"));
793        assert!(html.contains("Rust version"));
794    }
795
796    #[tokio::test]
797    async fn test_render_production_html_no_stack() {
798        let error = DebugError::new("SecretError", "internal db password=xxx", "secret.rs", 100)
799            .with_frame(StackFrame::new("frame.rs", 1, "secret_func"));
800        let config = DebugPageConfig::production();
801        // 通过 debug_error_response 验证生产模式行为(render_debug_html 始终渲染完整页,
802        // 由 debug_error_response 根据 debug_mode 选择渲染策略)
803        let response = debug_error_response(StatusCode::INTERNAL_SERVER_ERROR, &error, &config);
804        // 转 HTML 字符串验证内容
805        use http_body_util::BodyExt;
806        let bytes = response.into_body().collect().await.unwrap().to_bytes();
807        let html = String::from_utf8(bytes.to_vec()).unwrap();
808
809        // 生产模式应使用 production_html(不含堆栈/敏感信息)
810        assert!(!html.contains("SecretError"));
811        assert!(!html.contains("secret.rs"));
812        assert!(!html.contains("secret_func"));
813        assert!(!html.contains("password=xxx"));
814    }
815
816    // --------------------------------------------------------------------
817    // render_production_html
818    // --------------------------------------------------------------------
819
820    #[test]
821    fn test_render_production_html_basic() {
822        let html = render_production_html(StatusCode::INTERNAL_SERVER_ERROR, "Server Error");
823        assert!(html.contains("500"));
824        assert!(html.contains("Internal Server Error"));
825        assert!(html.contains("Server Error"));
826        assert!(html.contains("<!DOCTYPE html>"));
827    }
828
829    #[test]
830    fn test_render_production_html_escapes_message() {
831        let html = render_production_html(StatusCode::BAD_REQUEST, "<script>alert('xss')</script>");
832        assert!(!html.contains("<script>alert"));
833        assert!(html.contains("&lt;script&gt;"));
834    }
835
836    // --------------------------------------------------------------------
837    // debug_error_response
838    // --------------------------------------------------------------------
839
840    #[tokio::test]
841    async fn test_debug_error_response_debug_mode() {
842        let error = DebugError::new("TestError", "test", "test.rs", 1);
843        let config = DebugPageConfig::development();
844        let response = debug_error_response(StatusCode::INTERNAL_SERVER_ERROR, &error, &config);
845
846        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
847        assert_eq!(
848            response.headers().get("content-type").unwrap(),
849            "text/html; charset=utf-8"
850        );
851    }
852
853    #[tokio::test]
854    async fn test_debug_error_response_production_mode() {
855        let error = DebugError::new("TestError", "secret", "test.rs", 1);
856        let config = DebugPageConfig::production();
857        let response = debug_error_response(StatusCode::NOT_FOUND, &error, &config);
858
859        assert_eq!(response.status(), StatusCode::NOT_FOUND);
860        assert_eq!(
861            response.headers().get("content-type").unwrap(),
862            "text/html; charset=utf-8"
863        );
864    }
865
866    // --------------------------------------------------------------------
867    // sanitize_headers
868    // --------------------------------------------------------------------
869
870    #[test]
871    fn test_sanitize_headers_redacts_authorization() {
872        let mut headers = HeaderMap::new();
873        headers.insert("authorization", HeaderValue::from_static("Bearer xyz"));
874        headers.insert("cookie", HeaderValue::from_static("sess=abc"));
875        headers.insert("x-custom", HeaderValue::from_static("safe"));
876
877        let sanitized = sanitize_headers(&headers);
878
879        assert_eq!(sanitized.get("authorization").unwrap(), "<redacted>");
880        assert_eq!(sanitized.get("cookie").unwrap(), "<redacted>");
881        assert_eq!(sanitized.get("x-custom").unwrap(), "safe");
882    }
883
884    #[test]
885    fn test_sanitize_headers_empty() {
886        let headers = HeaderMap::new();
887        let sanitized = sanitize_headers(&headers);
888        assert!(sanitized.is_empty());
889    }
890
891    // --------------------------------------------------------------------
892    // DebugError
893    // --------------------------------------------------------------------
894
895    #[test]
896    fn test_debug_error_builder_pattern() {
897        let error = DebugError::new("ErrType", "msg", "file.rs", 10)
898            .with_frame(StackFrame::new("frame.rs", 5, "fn1"));
899
900        assert_eq!(error.error_type, "ErrType");
901        assert_eq!(error.message, "msg");
902        assert_eq!(error.file, "file.rs");
903        assert_eq!(error.line, 10);
904        assert_eq!(error.stack.len(), 1);
905        assert_eq!(error.stack[0].function, "fn1");
906    }
907
908    #[test]
909    fn test_debug_error_with_source_snippet_loads_main_frame() {
910        let temp = tempfile::tempdir().unwrap();
911        let file_path = temp.path().join("main.rs");
912        std::fs::write(&file_path, "line1\nline2\nline3\nline4\nline5\n").unwrap();
913
914        let error =
915            DebugError::new("Err", "msg", file_path.to_str().unwrap(), 3).with_source_snippet(1);
916
917        // 主错误位置应作为虚拟帧插入 stack[0]
918        assert!(!error.stack.is_empty());
919        assert_eq!(error.stack[0].line, 3);
920        assert!(!error.stack[0].source_lines.is_empty());
921    }
922}