yggdrasil/api/database/backup/
files.rs1use super::BackupInfo;
4use crate::api::error::AppError;
5use chrono::{DateTime, Utc};
6use std::fs::File;
7use std::path::{Component, Path, PathBuf};
8
9pub(super) const BACKUP_DIR: &str = "backups";
11const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";
13pub(super) const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
15
16pub(super) fn uploads_archive_name(sql_filename: &str) -> String {
18 format!("{}_uploads.tar.gz", sql_filename.trim_end_matches(".sql"))
19}
20
21pub(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
29pub(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
37fn 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
49pub(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}
81pub(super) fn backup_path(filename: &str) -> PathBuf {
93 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 PathBuf::from(BACKUP_DIR)
104 }
105}
106
107pub(super) fn is_valid_backup_filename(filename: &str) -> bool {
110 regex::Regex::new(FILENAME_RE)
112 .map(|re| re.is_match(filename))
113 .unwrap_or(false)
114}
115pub(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
127pub(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
139pub(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
149pub(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
158pub(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 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 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 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 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(_) => {} }
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 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 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 for evil in ["../etc/passwd", "../../etc/shadow"] {
315 let p = backup_path(evil);
316 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 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 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 let content = "SELECT * FROM users;\n-- YGGDRASIL BACKUP v1\n";
349 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 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 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 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 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", "auto_20260808_040000_uploads.tar.gz", "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 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}