Skip to main content

yggdrasil/api/database/backup/
files.rs

1//! 备份命名、签名、元信息、配对归档与自动轮转。
2
3use super::BackupInfo;
4use crate::api::error::AppError;
5use chrono::{DateTime, Utc};
6use std::fs::File;
7use std::path::{Component, Path, PathBuf};
8
9/// 与 uploads/ 同级且被 Git 忽略的备份目录。
10pub(super) const BACKUP_DIR: &str = "backups";
11/// 文件名白名单禁止目录分隔符与 shell 元字符。
12const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";
13/// 导入与恢复共同要求的备份签名。
14pub(super) const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
15
16/// SQL 文件名 → 配对 uploads 打包文件名(`X.sql` → `X_uploads.tar.gz`)。
17pub(super) fn uploads_archive_name(sql_filename: &str) -> String {
18    format!("{}_uploads.tar.gz", sql_filename.trim_end_matches(".sql"))
19}
20
21/// 备份文件名戳:`YYYYmmdd_HHMMSS_mmm_<6 位十六进制随机>`。
22/// 毫秒 + 随机后缀让同一时刻(含多实例共享 backups/)的两次备份不会撞名;
23/// 各段定宽,字典序仍即时间序(自动轮转依赖这一点)。
24pub(super) fn backup_stamp(now: DateTime<Utc>) -> String {
25    let random = uuid::Uuid::new_v4().simple().to_string();
26    format!("{}_{}", now.format("%Y%m%d_%H%M%S_%3f"), &random[..6])
27}
28
29/// 独占新建备份产物:目标已存在时报错而不是截断,杜绝两个任务写同一文件。
30pub(super) fn create_new_backup_file(path: &Path) -> std::io::Result<File> {
31    std::fs::OpenOptions::new()
32        .write(true)
33        .create_new(true)
34        .open(path)
35}
36
37/// 从自动备份 SQL 文件名列表挑出超出保留份数、应删除的最旧者。
38/// 文件名内嵌定宽时间戳,字典序即时间序。纯函数便于单测。
39fn select_expired_auto_backups(names: &[String], keep: usize) -> Vec<String> {
40    let mut autos: Vec<&String> = names
41        .iter()
42        .filter(|n| n.starts_with("auto_") && n.ends_with(".sql"))
43        .collect();
44    autos.sort();
45    let excess = autos.len().saturating_sub(keep);
46    autos.into_iter().take(excess).cloned().collect()
47}
48
49/// 执行自动备份轮转:删除超龄自动备份的 .sql 与配对 tar.gz。
50/// 单个删除失败只记日志,不中断其余。
51pub(super) fn rotate_auto_backups(keep: i32) {
52    rotate_auto_backups_in(Path::new(BACKUP_DIR), keep);
53}
54
55fn rotate_auto_backups_in(directory: &Path, keep: i32) {
56    let names: Vec<String> = match std::fs::read_dir(directory) {
57        Ok(entries) => entries
58            .flatten()
59            .map(|e| e.file_name().to_string_lossy().to_string())
60            .collect(),
61        Err(e) => {
62            tracing::warn!("backup rotation: cannot read {}: {e}", directory.display());
63            return;
64        }
65    };
66    for sql_name in select_expired_auto_backups(&names, keep.max(0) as usize) {
67        for path in [
68            directory.join(&sql_name),
69            directory.join(uploads_archive_name(&sql_name)),
70        ] {
71            match std::fs::remove_file(&path) {
72                Ok(()) => tracing::info!("backup rotation: deleted {}", path.display()),
73                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
74                Err(e) => {
75                    tracing::warn!("backup rotation: failed to delete {}: {e}", path.display())
76                }
77            }
78        }
79    }
80}
81/// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。
82///
83/// 纵深防御:即便第一道白名单 `is_valid_backup_filename` 被绕过,这里也要
84/// 保证结果不逃出 BACKUP_DIR。直接对 filename 做 components 检查——
85/// 含 `..`(ParentDir)、绝对路径前缀(RootDir/Prefix,如 `/etc` 或 `C:\`)
86/// 的 filename 一律降级为 BACKUP_DIR 本身。
87///
88/// 注意:不能用 `[BACKUP_DIR, filename].collect::<PathBuf>()` 后再检——
89/// 当 filename 是绝对路径时,PathBuf 语义会丢弃 BACKUP_DIR 前缀(如
90/// `["backups", "/etc/passwd"]` → `/etc/passwd`),导致 components 检查
91/// 在错位的路径上运行而漏判。必须先检 filename 本身。
92pub(super) fn backup_path(filename: &str) -> PathBuf {
93    // 直接检查 filename 的 components:只允许 Normal 段。
94    let filename_is_safe = std::path::Path::new(filename)
95        .components()
96        .all(|c| matches!(c, Component::Normal(_)));
97    if filename_is_safe {
98        let mut p = PathBuf::from(BACKUP_DIR);
99        p.push(filename);
100        p
101    } else {
102        // 命中 ParentDir/RootDir/Prefix/CurDir → 降级为 BACKUP_DIR
103        PathBuf::from(BACKUP_DIR)
104    }
105}
106
107/// 校验备份文件名是否符合白名单(仅字母数字下划线点连字符)。
108/// 返回 true 表示安全可用。提取为纯函数便于单测覆盖路径穿越边界。
109pub(super) fn is_valid_backup_filename(filename: &str) -> bool {
110    // regex::Regex::new 在 FILENAME_RE 是常量正则,编译期可验证不会 panic。
111    regex::Regex::new(FILENAME_RE)
112        .map(|re| re.is_match(filename))
113        .unwrap_or(false)
114}
115/// 查询 backups/ 所在文件系统的可用空间(取最长挂载点前缀的磁盘)。
116/// 预检用途:探测失败返回 None,降级为跳过预检(写入失败仍会清理并报错)。
117pub(super) fn backup_partition_free_space() -> Option<u64> {
118    let dir = std::fs::canonicalize(BACKUP_DIR).ok()?;
119    let disks = sysinfo::Disks::new_with_refreshed_list().ok()?;
120    disks
121        .iter()
122        .filter(|d| dir.starts_with(d.mount_point()))
123        .max_by_key(|d| d.mount_point().as_os_str().len())
124        .map(|d| d.available_space())
125}
126
127/// 从备份文件全文提取 `-- mode: <value>` 行的值(如 "pg_dump"/"sql-fallback")。
128/// 提取为纯函数:把文件内容作为参数传入,便于单测。
129/// 缺失或格式不符返回 "unknown"。
130pub(super) fn parse_backup_mode(content: &str) -> String {
131    content
132        .lines()
133        .find(|l| l.starts_with("-- mode:"))
134        .map(|l| l.trim_start_matches("-- mode:").trim().to_string())
135        .filter(|s| !s.is_empty())
136        .unwrap_or_else(|| "unknown".to_string())
137}
138
139/// 校验备份文件首行是否含本系统签名头。
140/// 提取为纯函数:把首行(或全文)作为参数传入,便于单测。
141pub(super) fn has_valid_signature(content: &str) -> bool {
142    content
143        .lines()
144        .next()
145        .map(|l| l.trim().contains(BACKUP_SIGNATURE))
146        .unwrap_or(false)
147}
148
149/// 仅读取文件首行(用于签名校验,避免把整个备份文件读入内存)。
150pub(super) fn read_first_line(path: impl AsRef<Path>) -> std::io::Result<String> {
151    use std::io::BufRead;
152    let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
153    let mut line = String::new();
154    reader.read_line(&mut line)?;
155    Ok(line)
156}
157
158/// 读取文件前 `n` 行(用于解析 `-- mode:` 等头部元信息,避免整文件读入内存)。
159/// 不足 `n` 行时返回实际读到的行;任一行读取失败则整体返回该错误。
160pub(super) fn read_first_lines(path: impl AsRef<Path>, n: usize) -> std::io::Result<Vec<String>> {
161    use std::io::BufRead;
162    let reader = std::io::BufReader::new(std::fs::File::open(path)?);
163    reader.lines().take(n).collect()
164}
165
166pub(super) fn list_backup_files() -> Vec<BackupInfo> {
167    let mut infos: Vec<BackupInfo> = Vec::new();
168    // 先收集 uploads 包:文件名 → 大小,供配对查询。
169    let mut tarballs: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
170    if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
171        for entry in entries.flatten() {
172            let name = entry.file_name().to_string_lossy().to_string();
173            if name.ends_with("_uploads.tar.gz") {
174                if let Ok(meta) = entry.metadata() {
175                    tarballs.insert(name, meta.len());
176                }
177            }
178        }
179    }
180    if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
181        for entry in entries.flatten() {
182            let name = entry.file_name().to_string_lossy().to_string();
183            if !name.ends_with(".sql") {
184                continue;
185            }
186            let meta = match entry.metadata() {
187                Ok(m) => m,
188                Err(_) => continue,
189            };
190            // 仅读取前 3 行(签名/created_at/mode),避免把每个(可能数十 MB
191            // 的)备份文件整文件读入内存只为取 `-- mode:` 行。
192            let mode = read_first_lines(entry.path(), 3)
193                .map(|lines| parse_backup_mode(&lines.join("\n")))
194                .unwrap_or_else(|_| "unknown".to_string());
195            let created_at = meta
196                .modified()
197                .ok()
198                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
199                .map(|d| {
200                    chrono::DateTime::<Utc>::from_timestamp(d.as_secs() as i64, 0)
201                        .map(|dt| dt.to_rfc3339())
202                        .unwrap_or_default()
203                });
204            let tar_name = uploads_archive_name(&name);
205            let (uploads_filename, uploads_size_bytes) = tarballs
206                .get_key_value(&tar_name)
207                .map(|(k, v)| (Some(k.clone()), Some(*v)))
208                .unwrap_or((None, None));
209            infos.push(BackupInfo {
210                origin: if name.starts_with("auto_") {
211                    "auto"
212                } else {
213                    "manual"
214                }
215                .to_string(),
216                filename: name,
217                size_bytes: meta.len(),
218                mode,
219                created_at,
220                uploads_filename,
221                uploads_size_bytes,
222            });
223        }
224    }
225    // 按创建时间降序(新的在前)
226    infos.sort_by(|a, b| b.created_at.cmp(&a.created_at));
227    infos
228}
229
230pub(super) fn delete_backup_files(filename: &str) -> Result<(), AppError> {
231    if !is_valid_backup_filename(filename) {
232        return Err(AppError::BadRequest("无效的文件名".to_string()));
233    }
234    let path = backup_path(filename);
235    if !path.exists() {
236        return Err(AppError::NotFound("备份文件不存在"));
237    }
238    std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?;
239    // 成对清理:传 .sql 删配对 tar.gz,传 tar.gz 删配对 .sql。
240    let pair = if filename.ends_with(".sql") {
241        Some(uploads_archive_name(filename))
242    } else {
243        filename
244            .strip_suffix("_uploads.tar.gz")
245            .map(|stem| format!("{stem}.sql"))
246    };
247    if let Some(pair_name) = pair {
248        match std::fs::remove_file(backup_path(&pair_name)) {
249            Ok(()) | Err(_) => {} // 配对缺失/删除失败不影响主文件删除结果
250        }
251    }
252    Ok(())
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn filename_accepts_normal_names() {
261        for name in [
262            "backup_20260702_120000.sql",
263            "backup_20260702_120000_sqlfallback.sql",
264            "a.sql",
265            "A-B_C.123",
266        ] {
267            assert!(is_valid_backup_filename(name), "正常文件名应通过: {name}");
268        }
269    }
270
271    #[test]
272    fn filename_rejects_path_traversal() {
273        // 路径穿越:白名单只允许字母数字下划线点连字符,/ 和 .. 都应被拒。
274        for evil in [
275            "../etc/passwd",
276            "..\\windows\\win.ini",
277            "/etc/passwd",
278            "a/../../b",
279            "backup.sql/../../etc",
280        ] {
281            assert!(!is_valid_backup_filename(evil), "路径穿越应被拒: {evil}");
282        }
283    }
284
285    #[test]
286    fn filename_rejects_spaces_and_special_chars() {
287        // 空格、中文、shell 元字符等都不在白名单。
288        for evil in [
289            "backup with space.sql",
290            "备份.sql",
291            "a;rm -rf.sql",
292            r"a\$b.sql",
293            "a`b`.sql",
294            "",
295        ] {
296            assert!(!is_valid_backup_filename(evil), "特殊字符应被拒: {evil:?}");
297        }
298    }
299
300    #[test]
301    fn backup_path_stays_in_backup_dir_for_normal_name() {
302        let p = backup_path("backup_20260702.sql");
303        assert!(p.starts_with(BACKUP_DIR), "应在 {BACKUP_DIR}/ 下");
304        assert_eq!(
305            p.file_name().and_then(|n| n.to_str()),
306            Some("backup_20260702.sql")
307        );
308    }
309
310    #[test]
311    fn backup_path_collapses_traversal_to_backup_dir() {
312        // 即便绕过白名单调用 backup_path(纵深防御),../ 也应被规约回 BACKUP_DIR,
313        // 而非指向 backups/ 之外。Component::ParentDir / RootDir 命中即降级。
314        for evil in ["../etc/passwd", "../../etc/shadow"] {
315            let p = backup_path(evil);
316            // 不应逃出 BACKUP_DIR(应为 BACKUP_DIR 本身,不含文件名)
317            assert_eq!(
318                p,
319                PathBuf::from(BACKUP_DIR),
320                "穿越应被规约回 {BACKUP_DIR}: {evil}"
321            );
322        }
323    }
324
325    #[test]
326    fn backup_path_rejects_absolute_path() {
327        // Component::RootDir 命中也应降级。
328        let p = backup_path("/etc/passwd");
329        assert_eq!(p, PathBuf::from(BACKUP_DIR));
330    }
331
332    #[test]
333    fn signature_matches_exact_header() {
334        let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\nSELECT 1;\n";
335        assert!(has_valid_signature(content));
336    }
337
338    #[test]
339    fn signature_matches_with_leading_whitespace() {
340        // 首行允许前导空白(trim 后匹配),容忍编辑器缩进。
341        let content = "  -- YGGDRASIL BACKUP v1\nrest\n";
342        assert!(has_valid_signature(content));
343    }
344
345    #[test]
346    fn signature_rejects_non_system_file() {
347        // 普通 SQL 文件首行不含签名 → 拒绝恢复(防任意文件读取/执行)。
348        let content = "SELECT * FROM users;\n-- YGGDRASIL BACKUP v1\n";
349        // 注意:签名必须在首行。第二行有签名不算。
350        assert!(!has_valid_signature(content));
351    }
352
353    #[test]
354    fn signature_rejects_empty_and_garbage() {
355        assert!(!has_valid_signature(""));
356        assert!(!has_valid_signature("garbage\n"));
357        assert!(!has_valid_signature("\n\n-- YGGDRASIL BACKUP v1"));
358    }
359
360    #[test]
361    fn parse_mode_pg_dump() {
362        let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\n...\n";
363        assert_eq!(parse_backup_mode(content), "pg_dump");
364    }
365
366    #[test]
367    fn parse_mode_sql_fallback() {
368        let content = "-- YGGDRASIL BACKUP v1\n-- mode: sql-fallback\n\n-- table: posts\n";
369        assert_eq!(parse_backup_mode(content), "sql-fallback");
370    }
371
372    #[test]
373    fn parse_mode_unknown_when_absent() {
374        let content = "-- YGGDRASIL BACKUP v1\nSELECT 1;\n";
375        assert_eq!(parse_backup_mode(content), "unknown");
376    }
377
378    #[test]
379    fn parse_mode_unknown_when_empty_value() {
380        // "-- mode:" 后无值 → unknown(防空字符串显示)
381        let content = "-- mode:\nrest\n";
382        assert_eq!(parse_backup_mode(content), "unknown");
383    }
384
385    #[test]
386    fn parse_mode_only_matches_first_occurrence() {
387        // 多个 -- mode: 行取第一个。
388        let content = "-- mode: pg_dump\n-- mode: sql-fallback\n";
389        assert_eq!(parse_backup_mode(content), "pg_dump");
390    }
391
392    #[test]
393    fn uploads_archive_name_pairs_with_sql() {
394        assert_eq!(
395            uploads_archive_name("backup_20260810_040000.sql"),
396            "backup_20260810_040000_uploads.tar.gz"
397        );
398        assert_eq!(
399            uploads_archive_name("auto_20260810_040000_sqlfallback.sql"),
400            "auto_20260810_040000_sqlfallback_uploads.tar.gz"
401        );
402    }
403
404    #[test]
405    fn backup_stamp_is_unique_within_same_instant_and_sorts_by_time() {
406        use chrono::TimeZone;
407        let t0 = Utc
408            .with_ymd_and_hms(2026, 11, 3, 4, 0, 0)
409            .single()
410            .expect("合法时间");
411        let first = backup_stamp(t0);
412        let second = backup_stamp(t0);
413        assert_ne!(first, second, "同一时刻的两次备份不得撞名");
414        assert!(first.starts_with("20261103_040000_000_"), "{first}");
415
416        // 晚 1 毫秒的备份必须排在后面,否则自动轮转会删错文件。
417        let later = backup_stamp(t0 + chrono::Duration::milliseconds(1));
418        let names = vec![format!("auto_{later}.sql"), format!("auto_{first}.sql")];
419        assert_eq!(
420            select_expired_auto_backups(&names, 1),
421            vec![format!("auto_{first}.sql")]
422        );
423    }
424
425    #[test]
426    fn create_new_backup_file_refuses_existing_file() {
427        let dir = std::env::temp_dir().join(format!(
428            "yggdrasil_backup_create_new_{}_{}",
429            std::process::id(),
430            uuid::Uuid::new_v4().simple()
431        ));
432        std::fs::create_dir_all(&dir).expect("创建测试目录");
433        let path = dir.join("backup.sql");
434        std::fs::write(&path, b"existing").expect("写测试文件");
435
436        let err = create_new_backup_file(&path).expect_err("已存在的备份不得被重新打开");
437        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
438        assert_eq!(
439            std::fs::read(&path).expect("读回"),
440            b"existing",
441            "原内容不得被截断"
442        );
443        std::fs::remove_dir_all(&dir).expect("清理测试目录");
444    }
445
446    #[test]
447    fn rotation_keeps_newest_and_deletes_oldest() {
448        let names: Vec<String> = [
449            "auto_20260808_040000.sql",
450            "auto_20260810_040000.sql",
451            "auto_20260809_040000.sql",
452        ]
453        .iter()
454        .map(|s| s.to_string())
455        .collect();
456        // 文件名时间戳定宽,乱序输入也应按时间排序。
457        assert_eq!(
458            select_expired_auto_backups(&names, 2),
459            vec!["auto_20260808_040000.sql".to_string()]
460        );
461    }
462
463    #[test]
464    fn rotation_ignores_manual_backups_and_tarballs() {
465        let names: Vec<String> = [
466            "backup_20260101_000000.sql",          // 手动:永不轮转
467            "auto_20260808_040000_uploads.tar.gz", // tar.gz:随配对 sql 删除,不单独计数
468            "auto_20260808_040000.sql",
469            "auto_20260809_040000.sql",
470        ]
471        .iter()
472        .map(|s| s.to_string())
473        .collect();
474        assert!(select_expired_auto_backups(&names, 2).is_empty());
475        assert_eq!(
476            select_expired_auto_backups(&names, 1),
477            vec!["auto_20260808_040000.sql".to_string()]
478        );
479    }
480
481    #[test]
482    fn rotation_empty_and_exact_keep() {
483        assert!(select_expired_auto_backups(&[], 5).is_empty());
484        let names: Vec<String> = ["auto_20260808_040000.sql"]
485            .iter()
486            .map(|s| s.to_string())
487            .collect();
488        assert!(select_expired_auto_backups(&names, 1).is_empty());
489        // keep=0 全删
490        assert_eq!(select_expired_auto_backups(&names, 0).len(), 1);
491    }
492    #[test]
493    fn rotation_removes_expired_sql_and_pair_but_preserves_manual_backups() {
494        let dir = std::env::temp_dir().join(format!(
495            "yggdrasil-backup-rotation-{}",
496            uuid::Uuid::new_v4()
497        ));
498        std::fs::create_dir(&dir).expect("create rotation fixture directory");
499        let names = [
500            "backup_20260101_000000.sql",
501            "auto_20260808_040000.sql",
502            "auto_20260809_040000.sql",
503            "auto_20260810_040000.sql",
504        ];
505        for name in names {
506            std::fs::write(dir.join(name), b"backup").expect("write SQL fixture");
507            std::fs::write(dir.join(uploads_archive_name(name)), b"uploads")
508                .expect("write paired uploads fixture");
509        }
510        let incomplete = dir.join(".import-test.tmp");
511        std::fs::write(&incomplete, b"partial").expect("write temporary import fixture");
512        rotate_auto_backups_in(&dir, 2);
513        for name in names {
514            let should_remain = name != "auto_20260808_040000.sql";
515            assert_eq!(dir.join(name).exists(), should_remain, "{name}");
516            assert_eq!(
517                dir.join(uploads_archive_name(name)).exists(),
518                should_remain,
519                "paired archive of {name}"
520            );
521        }
522        assert!(
523            incomplete.exists(),
524            "rotation only removes completed automatic backups"
525        );
526        std::fs::remove_dir_all(&dir).expect("clean rotation fixture");
527    }
528}