Skip to main content

yggdrasil/api/database/
backup.rs

1#![allow(clippy::unused_unit, deprecated)]
2
3//! 备份与恢复(读写,最高风险)。
4//!
5//! 备份:探测 pg_dump 可用性——可用则子进程生成完整 .sql(`--clean --if-exists`,
6//! 脚本自带 `DROP ... IF EXISTS`,使恢复幂等),不可用则回退纯 SQL(仅数据)。
7//! 备份文件含签名头。
8//! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护;
9//! `psql -v ON_ERROR_STOP=1` 确保任何 SQL 错误立即中止并报失败(不再假成功);
10//! 成功后全量失效文章缓存与 SSR 世代号。
11//! 长耗时操作走后台任务 + 进度轮询(见 [`crate::api::database::tasks`])。
12//!
13//! **兼容性提示**:`--clean --if-exists` 是后加的备份参数。本修复之前生成的备份
14//! 文件不含 DROP,对其执行恢复会在第一条「relation already exists」处中止并报
15//! 失败(行为正确,但无法恢复数据)——需重新创建备份才能恢复。
16
17// Component/PathBuf/chrono::Utc 仅 server 构建的备份逻辑用到。
18#[cfg(feature = "server")]
19use std::path::{Component, Path, PathBuf};
20
21#[cfg(feature = "server")]
22use chrono::Utc;
23use dioxus::prelude::*;
24use serde::{Deserialize, Serialize};
25
26// admin 鉴权 + AppError + tasks 进度表仅在 server 构建里被 server function 体引用。
27#[cfg(feature = "server")]
28use crate::api::auth::get_current_admin_user;
29#[cfg(feature = "server")]
30use crate::api::database::tasks::{self, TaskKind, TaskStatus};
31#[cfg(feature = "server")]
32use crate::api::error::AppError;
33
34// 以下常量仅被 server 构建的备份/恢复逻辑引用(WASM 构建里相关函数体被 cfg 剥掉,
35// 故常量也需 gate,否则非 server 构建会报 dead_code)。
36
37/// 备份目录(项目根,与 uploads/ 平级,gitignored)。
38#[cfg(feature = "server")]
39const BACKUP_DIR: &str = "backups";
40/// 文件名白名单正则:仅字母数字下划线点连字符(防路径穿越)。
41#[cfg(feature = "server")]
42const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";
43/// 备份文件签名头(恢复时校验,拒绝非本系统文件)。
44#[cfg(feature = "server")]
45const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
46
47/// 备份文件元信息(列表展示用)。
48#[derive(Serialize, Deserialize, Debug, Clone)]
49pub struct BackupInfo {
50    pub filename: String,
51    pub size_bytes: u64,
52    /// 备份模式:pg_dump / sql-fallback(从签名头解析)。
53    pub mode: String,
54    pub created_at: Option<String>,
55}
56
57/// 发起备份,立即返回 task_id,后台任务执行。
58#[server(CreateBackup, "/api")]
59pub async fn create_backup() -> Result<String, ServerFnError> {
60    let _user = get_current_admin_user().await?;
61
62    #[cfg(feature = "server")]
63    {
64        let task_id = uuid::Uuid::new_v4().to_string();
65        tasks::insert(task_id.clone(), TaskKind::Backup);
66        let tid = task_id.clone();
67        tokio::spawn(async move {
68            run_backup(&tid).await;
69        });
70        Ok(task_id)
71    }
72    #[cfg(not(feature = "server"))]
73    {
74        Ok(String::new())
75    }
76}
77
78/// 后台执行备份:pg_dump 优先,不可用回退纯 SQL。
79#[cfg(feature = "server")]
80async fn run_backup(task_id: &str) {
81    let _ = std::fs::create_dir_all(BACKUP_DIR);
82    let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
83
84    // 探测 pg_dump(fork+exec+wait 仍是阻塞系统调用,移出 tokio worker 线程)
85    let pg_dump_ok = tokio::task::spawn_blocking(|| {
86        std::process::Command::new("pg_dump")
87            .arg("--version")
88            .output()
89            .is_ok()
90    })
91    .await
92    .unwrap_or(false);
93
94    if pg_dump_ok {
95        run_pg_dump_backup(task_id, &timestamp).await;
96    } else {
97        run_sql_fallback_backup(task_id, &timestamp).await;
98    }
99}
100
101/// pg_dump 模式:子进程生成完整备份(含 schema),前置签名头。
102///
103/// `--clean --if-exists`:生成的脚本含 `DROP ... IF EXISTS`,使恢复幂等
104/// (恢复前自动删除现有对象,避免「relation already exists」/主键冲突导致
105/// 数据零写入)。详见 `run_restore`。
106#[cfg(feature = "server")]
107async fn run_pg_dump_backup(task_id: &str, timestamp: &str) {
108    tasks::update(
109        task_id,
110        "正在用 pg_dump 导出",
111        10,
112        TaskStatus::Running,
113        None,
114        None,
115        None,
116    );
117    let filename = format!("backup_{}.sql", timestamp);
118    let path = backup_path(&filename);
119    let db_url = match std::env::var("DATABASE_URL") {
120        Ok(u) if !u.is_empty() => u,
121        _ => {
122            tasks::update(
123                task_id,
124                "DATABASE_URL 未配置",
125                100,
126                TaskStatus::Failed,
127                None,
128                Some("pg_dump 备份需要 DATABASE_URL".to_string()),
129                None,
130            );
131            return;
132        }
133    };
134
135    let mut header = String::new();
136    header.push_str(&format!("{}\n", BACKUP_SIGNATURE));
137    header.push_str(&format!("-- created_at: {}\n", Utc::now()));
138    header.push_str("-- mode: pg_dump\n");
139
140    // 先写签名头,再追加 pg_dump 输出。
141    if let Err(e) = std::fs::write(&path, &header) {
142        tasks::update(
143            task_id,
144            "写入备份文件失败",
145            100,
146            TaskStatus::Failed,
147            None,
148            Some(format!("无法写入备份目录: {e}")),
149            None,
150        );
151        return;
152    }
153
154    let stdout_file = match std::fs::OpenOptions::new().append(true).open(&path) {
155        Ok(f) => f,
156        Err(e) => {
157            tasks::update(
158                task_id,
159                "pg_dump 启动失败",
160                100,
161                TaskStatus::Failed,
162                None,
163                Some(e.to_string()),
164                None,
165            );
166            return;
167        }
168    };
169    // pg_dump 导出可持续数十秒到数分钟,整个子进程生命周期(spawn + wait_with_output)
170    // 移入 spawn_blocking,避免阻塞 tokio worker 线程。注意 stdout 重定向到备份文件,
171    // 故不能用 .output()(它会用 piped 覆盖 stdout 配置);保留 spawn() + wait_with_output()
172    // 两段式。闭包返回 Result<Output, (bool, io::Error)>:true=启动(spawn)阶段失败,
173    // false=等待(wait)阶段失败,分别对应原有「启动失败」「执行失败」两条上报路径;
174    // 闭包 panic(JoinError)按「执行失败」处理。
175    let dump_result = tokio::task::spawn_blocking(
176        move || -> Result<std::process::Output, (bool, std::io::Error)> {
177            std::process::Command::new("pg_dump")
178                .arg(db_url)
179                // --clean --if-exists:生成 DROP ... IF EXISTS,让恢复幂等(先删后建),
180                // 否则恢复时表已存在 → CREATE/COPY 全部失败、数据零写入。
181                .arg("--clean")
182                .arg("--if-exists")
183                .stdout(std::process::Stdio::from(stdout_file))
184                .stderr(std::process::Stdio::piped())
185                .spawn()
186                .map_err(|e| (true, e))?
187                .wait_with_output()
188                .map_err(|e| (false, e))
189        },
190    )
191    .await
192    .unwrap_or_else(|join_e| Err((false, std::io::Error::other(join_e.to_string()))));
193    match dump_result {
194        Ok(o) if o.status.success() => {
195            tasks::update(
196                task_id,
197                "完成",
198                100,
199                TaskStatus::Done,
200                None,
201                None,
202                Some(filename),
203            );
204        }
205        Ok(o) => {
206            let stderr = String::from_utf8_lossy(&o.stderr).to_string();
207            tasks::update(
208                task_id,
209                "pg_dump 失败",
210                100,
211                TaskStatus::Failed,
212                None,
213                Some(stderr),
214                None,
215            );
216        }
217        Err((true, e)) => {
218            tasks::update(
219                task_id,
220                "pg_dump 启动失败",
221                100,
222                TaskStatus::Failed,
223                None,
224                Some(e.to_string()),
225                None,
226            );
227        }
228        Err((false, e)) => {
229            tasks::update(
230                task_id,
231                "pg_dump 执行失败",
232                100,
233                TaskStatus::Failed,
234                None,
235                Some(e.to_string()),
236                None,
237            );
238        }
239    }
240}
241
242/// 纯 SQL 回退:仅备份数据(不含 schema),按表计数精确进度。
243#[cfg(feature = "server")]
244async fn run_sql_fallback_backup(task_id: &str, timestamp: &str) {
245    tasks::update(
246        task_id,
247        "pg_dump 不可用,使用纯 SQL 回退(仅数据)",
248        10,
249        TaskStatus::Running,
250        Some("仅备份数据,不含 schema/索引/触发器".to_string()),
251        None,
252        None,
253    );
254    let filename = format!("backup_{}_sqlfallback.sql", timestamp);
255    let path = backup_path(&filename);
256
257    let client = match crate::db::pool::get_conn().await {
258        Ok(c) => c,
259        Err(e) => {
260            tasks::update(
261                task_id,
262                "数据库连接失败",
263                100,
264                TaskStatus::Failed,
265                None,
266                Some(e.to_string()),
267                None,
268            );
269            return;
270        }
271    };
272
273    // 取 public schema 下所有表名
274    let tables: Vec<String> = match client
275        .query(
276            "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
277            &[],
278        )
279        .await
280    {
281        Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(),
282        Err(e) => {
283            tasks::update(
284                task_id,
285                "读取表清单失败",
286                100,
287                TaskStatus::Failed,
288                None,
289                Some(e.to_string()),
290                None,
291            );
292            return;
293        }
294    };
295    let total = tables.len().max(1);
296
297    let mut out = String::new();
298    out.push_str(&format!("{}\n", BACKUP_SIGNATURE));
299    out.push_str(&format!("-- created_at: {}\n", Utc::now()));
300    out.push_str("-- mode: sql-fallback\n\n");
301
302    for (i, table) in tables.iter().enumerate() {
303        out.push_str(&format!("\n-- table: {}\n", table));
304        let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table);
305        match client.copy_out(&copy_stmt).await {
306            Ok(stream) => {
307                use futures::StreamExt;
308                // CopyOutStream 是 !Unpin,必须 pin 才能调 next。
309                tokio::pin!(stream);
310                while let Some(chunk) = stream.next().await {
311                    if let Ok(bytes) = chunk {
312                        out.push_str(&String::from_utf8_lossy(&bytes));
313                    }
314                }
315            }
316            Err(e) => {
317                out.push_str(&format!("-- 导出失败: {}\n", e));
318            }
319        }
320        // 按表更新进度(用 u32 避免大 schema 下的截断/溢出)
321        tasks::update(
322            task_id,
323            &format!("导出表 {}/{}", i + 1, total),
324            (10 + (i + 1) as u32 * 90 / total as u32).min(99) as u8,
325            TaskStatus::Running,
326            None,
327            None,
328            None,
329        );
330    }
331
332    if let Err(e) = std::fs::write(&path, out) {
333        tasks::update(
334            task_id,
335            "写入备份文件失败",
336            100,
337            TaskStatus::Failed,
338            None,
339            Some(format!("无法写入备份目录: {e}")),
340            None,
341        );
342        return;
343    }
344    tasks::update(
345        task_id,
346        "完成",
347        100,
348        TaskStatus::Done,
349        None,
350        None,
351        Some(filename),
352    );
353}
354
355/// 发起恢复:校验签名 + 路径穿越防护 + 二次确认,立即返回 task_id。
356#[server(RestoreBackup, "/api")]
357pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ServerFnError> {
358    let _user = get_current_admin_user().await?;
359
360    // 全部校验都在 server cfg 块内:confirm/regex/backup_path/std::fs 都是 server-only。
361    // WASM 侧的 server-function 客户端桩只返回 Ok(String::new())。
362    #[cfg(feature = "server")]
363    {
364        if !confirm {
365            return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
366        }
367        // 路径穿越防护
368        if !is_valid_backup_filename(&filename) {
369            return Err(AppError::BadRequest("无效的文件名".to_string()).into());
370        }
371        let path = backup_path(&filename);
372        if !path.exists() {
373            return Err(AppError::NotFound("备份文件不存在").into());
374        }
375
376        // 签名校验:仅读取首行(备份文件可达数十 MB,无需整文件读入内存)。
377        let first_line = read_first_line(&path).unwrap_or_default();
378        if !has_valid_signature(&first_line) {
379            return Err(
380                AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into(),
381            );
382        }
383
384        let task_id = uuid::Uuid::new_v4().to_string();
385        tasks::insert(task_id.clone(), TaskKind::Restore);
386        let tid = task_id.clone();
387        let f = filename;
388        tokio::spawn(async move {
389            run_restore(&tid, &f).await;
390        });
391        Ok(task_id)
392    }
393    #[cfg(not(feature = "server"))]
394    {
395        // WASM 客户端桩:忽略参数,返回空 task_id。
396        let _ = (filename, confirm);
397        Ok(String::new())
398    }
399}
400
401/// 后台执行恢复:探测 psql,可用则 psql -f,不可用则报告。
402///
403/// **幂等恢复**:备份由 `pg_dump --clean --if-exists` 生成,脚本自带
404/// `DROP ... IF EXISTS`,恢复时会先删后建,数据完全回到备份时刻。
405///
406/// **错误中止**:`-v ON_ERROR_STOP=1` 让 psql 在第一条 SQL 错误时立即退出
407/// (退出码 3)。否则 psql 即使满屏 ERROR 也返回 0,导致 `status.success()`
408/// 误判为成功——这是「恢复完成却无任何数据变更」假成功的根因。
409///
410/// **恢复成功后失效全量缓存**:恢复会用备份时刻的数据重建 posts 等表,
411/// 现有 moka 缓存(列表/标签/单篇/统计/搜索)与 SSR 世代号必须一并冲刷,
412/// 否则前端仍读旧数据。
413#[cfg(feature = "server")]
414async fn run_restore(task_id: &str, filename: &str) {
415    let path = backup_path(filename);
416    let db_url = match std::env::var("DATABASE_URL") {
417        Ok(u) if !u.is_empty() => u,
418        _ => {
419            tasks::update(
420                task_id,
421                "DATABASE_URL 未配置",
422                100,
423                TaskStatus::Failed,
424                None,
425                Some("恢复需要 DATABASE_URL".to_string()),
426                None,
427            );
428            return;
429        }
430    };
431    let psql_ok = tokio::task::spawn_blocking(|| {
432        std::process::Command::new("psql")
433            .arg("--version")
434            .output()
435            .is_ok()
436    })
437    .await
438    .unwrap_or(false);
439    if !psql_ok {
440        tasks::update(
441            task_id,
442            "psql 不可用",
443            100,
444            TaskStatus::Failed,
445            None,
446            Some("恢复需要 psql,但当前环境未安装 psql".to_string()),
447            None,
448        );
449        return;
450    }
451    tasks::update(
452        task_id,
453        "正在用 psql 恢复",
454        50,
455        TaskStatus::Running,
456        None,
457        None,
458        None,
459    );
460    // psql 恢复可持续数十秒到数分钟,整段 .output() 移入 spawn_blocking,避免阻塞
461    // tokio worker 线程。db_url/path 按值移入闭包(闭包外不再使用);闭包 panic
462    // (JoinError)按「启动失败」上报。
463    let restore_result = tokio::task::spawn_blocking(move || {
464        std::process::Command::new("psql")
465            .arg(db_url)
466            // ON_ERROR_STOP=1:遇 SQL 错误立即中止(退出码 3)。
467            // 不加这个,psql 即使满屏 ERROR 也返回 0,status.success() 误报成功。
468            .arg("-v")
469            .arg("ON_ERROR_STOP=1")
470            .arg("-f")
471            .arg(path)
472            .stderr(std::process::Stdio::piped())
473            .output()
474    })
475    .await
476    .unwrap_or_else(|join_e| Err(std::io::Error::other(join_e.to_string())));
477    match restore_result {
478        Ok(o) if o.status.success() => {
479            // 恢复用备份时刻的数据重建了 posts 等表,必须冲刷全部文章相关缓存
480            // 与 SSR 世代号,否则前端仍读旧数据(被删的文章不会重新出现)。
481            crate::cache::invalidate_all_post_caches();
482            crate::cache::invalidate_search_results();
483            crate::ssr_cache::bump_global_generation();
484            tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None);
485        }
486        Ok(o) => {
487            let stderr = String::from_utf8_lossy(&o.stderr).to_string();
488            tasks::update(
489                task_id,
490                "恢复失败",
491                100,
492                TaskStatus::Failed,
493                None,
494                Some(stderr),
495                None,
496            );
497        }
498        Err(e) => {
499            tasks::update(
500                task_id,
501                "psql 启动失败",
502                100,
503                TaskStatus::Failed,
504                None,
505                Some(e.to_string()),
506                None,
507            );
508        }
509    }
510}
511
512/// 列出 backups/ 目录下的备份文件元信息。
513#[server(ListBackups, "/api")]
514pub async fn list_backups() -> Result<Vec<BackupInfo>, ServerFnError> {
515    let _user = get_current_admin_user().await?;
516    #[cfg(feature = "server")]
517    {
518        let mut infos: Vec<BackupInfo> = Vec::new();
519        if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
520            for entry in entries.flatten() {
521                let name = entry.file_name().to_string_lossy().to_string();
522                if !name.ends_with(".sql") {
523                    continue;
524                }
525                let meta = match entry.metadata() {
526                    Ok(m) => m,
527                    Err(_) => continue,
528                };
529                // 仅读取前 3 行(签名/created_at/mode),避免把每个(可能数十 MB
530                // 的)备份文件整文件读入内存只为取 `-- mode:` 行。
531                let mode = read_first_lines(entry.path(), 3)
532                    .map(|lines| parse_backup_mode(&lines.join("\n")))
533                    .unwrap_or_else(|_| "unknown".to_string());
534                let created_at = meta
535                    .modified()
536                    .ok()
537                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
538                    .map(|d| {
539                        chrono::DateTime::<Utc>::from_timestamp(d.as_secs() as i64, 0)
540                            .map(|dt| dt.to_rfc3339())
541                            .unwrap_or_default()
542                    });
543                infos.push(BackupInfo {
544                    filename: name,
545                    size_bytes: meta.len(),
546                    mode,
547                    created_at,
548                });
549            }
550        }
551        // 按创建时间降序(新的在前)
552        infos.sort_by(|a, b| b.created_at.cmp(&a.created_at));
553        Ok(infos)
554    }
555    #[cfg(not(feature = "server"))]
556    {
557        Ok(vec![])
558    }
559}
560
561/// 删除备份文件。
562#[server(DeleteBackup, "/api")]
563pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> {
564    let _user = get_current_admin_user().await?;
565    #[cfg(feature = "server")]
566    {
567        if !is_valid_backup_filename(&filename) {
568            return Err(AppError::BadRequest("无效的文件名".to_string()).into());
569        }
570        let path = backup_path(&filename);
571        if !path.exists() {
572            return Err(AppError::NotFound("备份文件不存在").into());
573        }
574        std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?;
575        Ok(())
576    }
577    #[cfg(not(feature = "server"))]
578    {
579        Ok(())
580    }
581}
582
583/// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。
584///
585/// 纵深防御:即便第一道白名单 `is_valid_backup_filename` 被绕过,这里也要
586/// 保证结果不逃出 BACKUP_DIR。直接对 filename 做 components 检查——
587/// 含 `..`(ParentDir)、绝对路径前缀(RootDir/Prefix,如 `/etc` 或 `C:\`)
588/// 的 filename 一律降级为 BACKUP_DIR 本身。
589///
590/// 注意:不能用 `[BACKUP_DIR, filename].collect::<PathBuf>()` 后再检——
591/// 当 filename 是绝对路径时,PathBuf 语义会丢弃 BACKUP_DIR 前缀(如
592/// `["backups", "/etc/passwd"]` → `/etc/passwd`),导致 components 检查
593/// 在错位的路径上运行而漏判。必须先检 filename 本身。
594#[cfg(feature = "server")]
595fn backup_path(filename: &str) -> PathBuf {
596    // 直接检查 filename 的 components:只允许 Normal 段。
597    let filename_is_safe = std::path::Path::new(filename)
598        .components()
599        .all(|c| matches!(c, Component::Normal(_)));
600    if filename_is_safe {
601        let mut p = PathBuf::from(BACKUP_DIR);
602        p.push(filename);
603        p
604    } else {
605        // 命中 ParentDir/RootDir/Prefix/CurDir → 降级为 BACKUP_DIR
606        PathBuf::from(BACKUP_DIR)
607    }
608}
609
610/// 校验备份文件名是否符合白名单(仅字母数字下划线点连字符)。
611/// 返回 true 表示安全可用。提取为纯函数便于单测覆盖路径穿越边界。
612#[cfg(feature = "server")]
613fn is_valid_backup_filename(filename: &str) -> bool {
614    // regex::Regex::new 在 FILENAME_RE 是常量正则,编译期可验证不会 panic。
615    regex::Regex::new(FILENAME_RE)
616        .map(|re| re.is_match(filename))
617        .unwrap_or(false)
618}
619
620/// 从备份文件全文提取 `-- mode: <value>` 行的值(如 "pg_dump"/"sql-fallback")。
621/// 提取为纯函数:把文件内容作为参数传入,便于单测。
622/// 缺失或格式不符返回 "unknown"。
623#[cfg(feature = "server")]
624fn parse_backup_mode(content: &str) -> String {
625    content
626        .lines()
627        .find(|l| l.starts_with("-- mode:"))
628        .map(|l| l.trim_start_matches("-- mode:").trim().to_string())
629        .filter(|s| !s.is_empty())
630        .unwrap_or_else(|| "unknown".to_string())
631}
632
633/// 校验备份文件首行是否含本系统签名头。
634/// 提取为纯函数:把首行(或全文)作为参数传入,便于单测。
635#[cfg(feature = "server")]
636fn has_valid_signature(content: &str) -> bool {
637    content
638        .lines()
639        .next()
640        .map(|l| l.trim().contains(BACKUP_SIGNATURE))
641        .unwrap_or(false)
642}
643
644/// 仅读取文件首行(用于签名校验,避免把整个备份文件读入内存)。
645#[cfg(feature = "server")]
646fn read_first_line(path: impl AsRef<Path>) -> std::io::Result<String> {
647    use std::io::BufRead;
648    let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
649    let mut line = String::new();
650    reader.read_line(&mut line)?;
651    Ok(line)
652}
653
654/// 读取文件前 `n` 行(用于解析 `-- mode:` 等头部元信息,避免整文件读入内存)。
655/// 不足 `n` 行时返回实际读到的行;任一行读取失败则整体返回该错误。
656#[cfg(feature = "server")]
657fn read_first_lines(path: impl AsRef<Path>, n: usize) -> std::io::Result<Vec<String>> {
658    use std::io::BufRead;
659    let reader = std::io::BufReader::new(std::fs::File::open(path)?);
660    reader.lines().take(n).collect()
661}
662
663/// Axum 处理器:下载备份文件(admin 鉴权 + 路径白名单)。
664/// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。
665#[cfg(feature = "server")]
666pub async fn download_backup(
667    axum::extract::Path(filename): axum::extract::Path<String>,
668    headers: axum::http::HeaderMap,
669) -> Result<impl axum::response::IntoResponse, (axum::http::StatusCode, String)> {
670    use axum::http::{header, StatusCode};
671
672    // 鉴权
673    let cookie_header = headers
674        .get("cookie")
675        .and_then(|h| h.to_str().ok())
676        .unwrap_or("");
677    let token = crate::auth::session::parse_session_token(cookie_header).map(str::to_string);
678    let token = match token {
679        Some(t) => t,
680        None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
681    };
682    let user = match crate::api::auth::get_user_by_token(&token).await {
683        Ok(Some(u)) => u,
684        _ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
685    };
686    if user.role != crate::models::user::UserRole::Admin {
687        return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
688    }
689
690    // 路径白名单
691    if !is_valid_backup_filename(&filename) {
692        return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string()));
693    }
694    let path = backup_path(&filename);
695    let bytes = tokio::fs::read(&path)
696        .await
697        .map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?;
698    let disposition = format!("attachment; filename=\"{}\"", filename);
699    Ok((
700        StatusCode::OK,
701        [
702            (
703                header::CONTENT_TYPE,
704                axum::http::HeaderValue::from_static("application/sql; charset=utf-8"),
705            ),
706            (
707                header::CONTENT_DISPOSITION,
708                axum::http::HeaderValue::from_str(&disposition)
709                    .unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")),
710            ),
711        ],
712        axum::body::Body::from(bytes),
713    ))
714}
715
716#[cfg(all(test, feature = "server"))]
717mod tests {
718    use super::*;
719
720    // ── is_valid_backup_filename:文件名白名单(路径穿越第一道防线) ──
721
722    #[test]
723    fn filename_accepts_normal_names() {
724        for name in [
725            "backup_20260702_120000.sql",
726            "backup_20260702_120000_sqlfallback.sql",
727            "a.sql",
728            "A-B_C.123",
729        ] {
730            assert!(is_valid_backup_filename(name), "正常文件名应通过: {name}");
731        }
732    }
733
734    #[test]
735    fn filename_rejects_path_traversal() {
736        // 路径穿越:白名单只允许字母数字下划线点连字符,/ 和 .. 都应被拒。
737        for evil in [
738            "../etc/passwd",
739            "..\\windows\\win.ini",
740            "/etc/passwd",
741            "a/../../b",
742            "backup.sql/../../etc",
743        ] {
744            assert!(!is_valid_backup_filename(evil), "路径穿越应被拒: {evil}");
745        }
746    }
747
748    #[test]
749    fn filename_rejects_spaces_and_special_chars() {
750        // 空格、中文、shell 元字符等都不在白名单。
751        for evil in [
752            "backup with space.sql",
753            "备份.sql",
754            "a;rm -rf.sql",
755            r"a\$b.sql",
756            "a`b`.sql",
757            "",
758        ] {
759            assert!(!is_valid_backup_filename(evil), "特殊字符应被拒: {evil:?}");
760        }
761    }
762
763    // ── backup_path:路径穿越纵深防御(白名单之外的二次防御) ────────
764
765    #[test]
766    fn backup_path_stays_in_backup_dir_for_normal_name() {
767        let p = backup_path("backup_20260702.sql");
768        assert!(p.starts_with(BACKUP_DIR), "应在 {BACKUP_DIR}/ 下");
769        assert_eq!(
770            p.file_name().and_then(|n| n.to_str()),
771            Some("backup_20260702.sql")
772        );
773    }
774
775    #[test]
776    fn backup_path_collapses_traversal_to_backup_dir() {
777        // 即便绕过白名单调用 backup_path(纵深防御),../ 也应被规约回 BACKUP_DIR,
778        // 而非指向 backups/ 之外。Component::ParentDir / RootDir 命中即降级。
779        for evil in ["../etc/passwd", "../../etc/shadow"] {
780            let p = backup_path(evil);
781            // 不应逃出 BACKUP_DIR(应为 BACKUP_DIR 本身,不含文件名)
782            assert_eq!(
783                p,
784                PathBuf::from(BACKUP_DIR),
785                "穿越应被规约回 {BACKUP_DIR}: {evil}"
786            );
787        }
788    }
789
790    #[test]
791    fn backup_path_rejects_absolute_path() {
792        // Component::RootDir 命中也应降级。
793        let p = backup_path("/etc/passwd");
794        assert_eq!(p, PathBuf::from(BACKUP_DIR));
795    }
796
797    // ── has_valid_signature:备份签名校验(拒绝非本系统文件) ───────
798
799    #[test]
800    fn signature_matches_exact_header() {
801        let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\nSELECT 1;\n";
802        assert!(has_valid_signature(content));
803    }
804
805    #[test]
806    fn signature_matches_with_leading_whitespace() {
807        // 首行允许前导空白(trim 后匹配),容忍编辑器缩进。
808        let content = "  -- YGGDRASIL BACKUP v1\nrest\n";
809        assert!(has_valid_signature(content));
810    }
811
812    #[test]
813    fn signature_rejects_non_system_file() {
814        // 普通 SQL 文件首行不含签名 → 拒绝恢复(防任意文件读取/执行)。
815        let content = "SELECT * FROM users;\n-- YGGDRASIL BACKUP v1\n";
816        // 注意:签名必须在首行。第二行有签名不算。
817        assert!(!has_valid_signature(content));
818    }
819
820    #[test]
821    fn signature_rejects_empty_and_garbage() {
822        assert!(!has_valid_signature(""));
823        assert!(!has_valid_signature("garbage\n"));
824        assert!(!has_valid_signature("\n\n-- YGGDRASIL BACKUP v1"));
825    }
826
827    // ── parse_backup_mode:模式解析(列表展示用) ───────────────────
828
829    #[test]
830    fn parse_mode_pg_dump() {
831        let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\n...\n";
832        assert_eq!(parse_backup_mode(content), "pg_dump");
833    }
834
835    #[test]
836    fn parse_mode_sql_fallback() {
837        let content = "-- YGGDRASIL BACKUP v1\n-- mode: sql-fallback\n\n-- table: posts\n";
838        assert_eq!(parse_backup_mode(content), "sql-fallback");
839    }
840
841    #[test]
842    fn parse_mode_unknown_when_absent() {
843        let content = "-- YGGDRASIL BACKUP v1\nSELECT 1;\n";
844        assert_eq!(parse_backup_mode(content), "unknown");
845    }
846
847    #[test]
848    fn parse_mode_unknown_when_empty_value() {
849        // "-- mode:" 后无值 → unknown(防空字符串显示)
850        let content = "-- mode:\nrest\n";
851        assert_eq!(parse_backup_mode(content), "unknown");
852    }
853
854    #[test]
855    fn parse_mode_only_matches_first_occurrence() {
856        // 多个 -- mode: 行取第一个。
857        let content = "-- mode: pg_dump\n-- mode: sql-fallback\n";
858        assert_eq!(parse_backup_mode(content), "pg_dump");
859    }
860}