1#![allow(clippy::unused_unit, deprecated)]
2
3#[cfg(feature = "server")]
31use std::path::{Component, Path, PathBuf};
32
33#[cfg(feature = "server")]
34use chrono::Utc;
35use dioxus::prelude::*;
36use serde::{Deserialize, Serialize};
37
38#[cfg(feature = "server")]
40use crate::api::auth::get_current_admin_user;
41#[cfg(feature = "server")]
42use crate::api::database::tasks::{self, TaskKind, TaskStatus};
43#[cfg(feature = "server")]
44use crate::api::error::AppError;
45
46#[cfg(feature = "server")]
51const BACKUP_DIR: &str = "backups";
52#[cfg(feature = "server")]
54const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";
55#[cfg(feature = "server")]
57const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
58#[cfg(feature = "server")]
60const DEFAULT_IMPORT_MAX_MB: u64 = 512;
61#[cfg(feature = "server")]
64pub(crate) const MULTIPART_FRAME_SLACK: u64 = 1024 * 1024;
65
66#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
68pub struct BackupInfo {
69 pub filename: String,
70 pub size_bytes: u64,
71 pub mode: String,
73 pub created_at: Option<String>,
74 pub origin: String,
76 pub uploads_filename: Option<String>,
78 pub uploads_size_bytes: Option<u64>,
79}
80
81#[cfg(feature = "server")]
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub(crate) enum BackupOrigin {
85 Manual,
87 Auto,
89}
90
91#[cfg(feature = "server")]
92impl BackupOrigin {
93 fn file_prefix(self) -> &'static str {
94 match self {
95 Self::Manual => "backup",
96 Self::Auto => "auto",
97 }
98 }
99}
100
101#[cfg(feature = "server")]
103#[derive(Debug)]
104pub(crate) struct BackupRunOutcome {
105 pub sql_filename: String,
107 pub uploads_filename: Option<String>,
109 pub warning: Option<String>,
111}
112
113#[server(CreateBackup, "/api")]
115pub async fn create_backup() -> Result<String, ServerFnError> {
116 let _user = get_current_admin_user().await?;
117
118 #[cfg(feature = "server")]
119 {
120 let task_id = uuid::Uuid::new_v4().to_string();
121 tasks::insert(task_id.clone(), TaskKind::Backup);
122 let tid = task_id.clone();
123 tokio::spawn(async move {
124 let _ = run_backup(&tid, BackupOrigin::Manual).await;
126 });
127 Ok(task_id)
128 }
129 #[cfg(not(feature = "server"))]
130 {
131 Ok(String::new())
132 }
133}
134
135#[cfg(feature = "server")]
137pub(crate) async fn run_auto_backup() -> Result<BackupRunOutcome, String> {
138 let task_id = uuid::Uuid::new_v4().to_string();
139 tasks::insert(task_id.clone(), TaskKind::Backup);
140 run_backup(&task_id, BackupOrigin::Auto).await
141}
142
143#[cfg(feature = "server")]
147async fn run_backup(task_id: &str, origin: BackupOrigin) -> Result<BackupRunOutcome, String> {
148 let _ = std::fs::create_dir_all(BACKUP_DIR);
149 let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
150 let prefix = origin.file_prefix();
151
152 let settings = match crate::db::pool::get_conn().await {
155 Ok(conn) => crate::api::settings::load_backup_settings(&conn)
156 .await
157 .unwrap_or_default(),
158 Err(_) => crate::models::settings::BackupSettings::default(),
159 };
160
161 let pg_dump_ok = tokio::task::spawn_blocking(|| {
163 std::process::Command::new("pg_dump")
164 .arg("--version")
165 .output()
166 .is_ok()
167 })
168 .await
169 .unwrap_or(false);
170
171 let sql_result = if pg_dump_ok {
172 run_pg_dump_backup(task_id, prefix, ×tamp).await
173 } else {
174 run_sql_fallback_backup(task_id, prefix, ×tamp).await
175 };
176 let sql_filename = match sql_result {
177 Ok(f) => f,
178 Err(e) => return Err(e),
180 };
181
182 let mut uploads_filename = None;
184 let mut warning = None;
185 if settings.include_uploads {
186 tasks::update(
187 task_id,
188 "正在打包 uploads 素材",
189 92,
190 TaskStatus::Running,
191 None,
192 None,
193 None,
194 );
195 let tar_name = uploads_archive_name(&sql_filename);
196 let tar_path = backup_path(&tar_name);
197 match tokio::task::spawn_blocking(move || {
198 create_uploads_tarball(Path::new("uploads"), &tar_path)
199 })
200 .await
201 {
202 Ok(Ok(())) => uploads_filename = Some(tar_name),
203 Ok(Err(e)) => warning = Some(format!("uploads 打包失败: {e}")),
204 Err(e) => warning = Some(format!("uploads 打包任务panic: {e}")),
205 }
206 if warning.is_some() {
207 tracing::warn!("backup uploads tarball failed: {:?}", warning);
208 }
209 }
210
211 if origin == BackupOrigin::Auto {
213 rotate_auto_backups(settings.retention_count);
214 }
215
216 tasks::update(
217 task_id,
218 "完成",
219 100,
220 TaskStatus::Done,
221 warning.clone(),
222 None,
223 Some(sql_filename.clone()),
224 );
225 Ok(BackupRunOutcome {
226 sql_filename,
227 uploads_filename,
228 warning,
229 })
230}
231
232#[cfg(feature = "server")]
234fn uploads_archive_name(sql_filename: &str) -> String {
235 format!("{}_uploads.tar.gz", sql_filename.trim_end_matches(".sql"))
236}
237
238#[cfg(feature = "server")]
242fn create_uploads_tarball(uploads_dir: &Path, out_path: &Path) -> std::io::Result<()> {
243 let file = std::fs::File::create(out_path)?;
244 let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
245 let mut builder = tar::Builder::new(gz);
246 if uploads_dir.is_dir() {
247 for entry in std::fs::read_dir(uploads_dir)? {
248 let entry = entry?;
249 let name = entry.file_name();
250 if name == ".cache" || name == ".gitkeep" {
251 continue;
252 }
253 let path = entry.path();
254 if path.is_dir() {
255 builder.append_dir_all(&name, &path)?;
256 } else if path.is_file() {
257 builder.append_path_with_name(&path, &name)?;
258 }
259 }
260 }
261 let gz = builder.into_inner()?;
262 gz.finish()?;
263 Ok(())
264}
265
266#[cfg(feature = "server")]
269fn select_expired_auto_backups(names: &[String], keep: usize) -> Vec<String> {
270 let mut autos: Vec<&String> = names
271 .iter()
272 .filter(|n| n.starts_with("auto_") && n.ends_with(".sql"))
273 .collect();
274 autos.sort();
275 let excess = autos.len().saturating_sub(keep);
276 autos.into_iter().take(excess).cloned().collect()
277}
278
279#[cfg(feature = "server")]
282fn rotate_auto_backups(keep: i32) {
283 let names: Vec<String> = match std::fs::read_dir(BACKUP_DIR) {
284 Ok(entries) => entries
285 .flatten()
286 .map(|e| e.file_name().to_string_lossy().to_string())
287 .collect(),
288 Err(e) => {
289 tracing::warn!("backup rotation: cannot read {BACKUP_DIR}: {e}");
290 return;
291 }
292 };
293 for sql_name in select_expired_auto_backups(&names, keep.max(0) as usize) {
294 for path in [
295 backup_path(&sql_name),
296 backup_path(&uploads_archive_name(&sql_name)),
297 ] {
298 match std::fs::remove_file(&path) {
299 Ok(()) => tracing::info!("backup rotation: deleted {}", path.display()),
300 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
301 Err(e) => {
302 tracing::warn!("backup rotation: failed to delete {}: {e}", path.display())
303 }
304 }
305 }
306 }
307}
308
309#[cfg(feature = "server")]
316fn pg_dump_command(db_url: &str) -> std::process::Command {
317 let mut command = std::process::Command::new("pg_dump");
318 command
319 .arg(db_url)
320 .arg("--clean")
321 .arg("--if-exists")
322 .arg("--no-owner")
323 .arg("--no-privileges");
324 command
325}
326
327#[cfg(feature = "server")]
328async fn run_pg_dump_backup(
329 task_id: &str,
330 prefix: &str,
331 timestamp: &str,
332) -> Result<String, String> {
333 tasks::update(
334 task_id,
335 "正在用 pg_dump 导出",
336 10,
337 TaskStatus::Running,
338 None,
339 None,
340 None,
341 );
342 let filename = format!("{prefix}_{timestamp}.sql");
343 let path = backup_path(&filename);
344 let db_url = match std::env::var("DATABASE_URL") {
345 Ok(u) if !u.is_empty() => u,
346 _ => {
347 let msg = "pg_dump 备份需要 DATABASE_URL".to_string();
348 tasks::update(
349 task_id,
350 "DATABASE_URL 未配置",
351 100,
352 TaskStatus::Failed,
353 None,
354 Some(msg.clone()),
355 None,
356 );
357 return Err(msg);
358 }
359 };
360
361 let mut header = String::new();
362 header.push_str(&format!("{}\n", BACKUP_SIGNATURE));
363 header.push_str(&format!("-- created_at: {}\n", Utc::now()));
364 header.push_str("-- mode: pg_dump\n");
365
366 if let Err(e) = std::fs::write(&path, &header) {
368 let msg = format!("无法写入备份目录: {e}");
369 tasks::update(
370 task_id,
371 "写入备份文件失败",
372 100,
373 TaskStatus::Failed,
374 None,
375 Some(msg.clone()),
376 None,
377 );
378 return Err(msg);
379 }
380
381 let stdout_file = match std::fs::OpenOptions::new().append(true).open(&path) {
382 Ok(f) => f,
383 Err(e) => {
384 let msg = e.to_string();
385 tasks::update(
386 task_id,
387 "pg_dump 启动失败",
388 100,
389 TaskStatus::Failed,
390 None,
391 Some(msg.clone()),
392 None,
393 );
394 return Err(msg);
395 }
396 };
397 let dump_result = tokio::task::spawn_blocking(
404 move || -> Result<std::process::Output, (bool, std::io::Error)> {
405 let mut command = pg_dump_command(&db_url);
406 command
407 .stdout(std::process::Stdio::from(stdout_file))
408 .stderr(std::process::Stdio::piped())
409 .spawn()
410 .map_err(|e| (true, e))?
411 .wait_with_output()
412 .map_err(|e| (false, e))
413 },
414 )
415 .await
416 .unwrap_or_else(|join_e| Err((false, std::io::Error::other(join_e.to_string()))));
417 match dump_result {
418 Ok(o) if o.status.success() => Ok(filename),
419 Ok(o) => {
420 let msg = String::from_utf8_lossy(&o.stderr).to_string();
421 tasks::update(
422 task_id,
423 "pg_dump 失败",
424 100,
425 TaskStatus::Failed,
426 None,
427 Some(msg.clone()),
428 None,
429 );
430 Err(msg)
431 }
432 Err((true, e)) => {
433 let msg = e.to_string();
434 tasks::update(
435 task_id,
436 "pg_dump 启动失败",
437 100,
438 TaskStatus::Failed,
439 None,
440 Some(msg.clone()),
441 None,
442 );
443 Err(msg)
444 }
445 Err((false, e)) => {
446 let msg = e.to_string();
447 tasks::update(
448 task_id,
449 "pg_dump 执行失败",
450 100,
451 TaskStatus::Failed,
452 None,
453 Some(msg.clone()),
454 None,
455 );
456 Err(msg)
457 }
458 }
459}
460
461#[cfg(feature = "server")]
469async fn run_sql_fallback_backup(
470 task_id: &str,
471 prefix: &str,
472 timestamp: &str,
473) -> Result<String, String> {
474 tasks::update(
475 task_id,
476 "pg_dump 不可用,使用纯 SQL 回退(仅数据)",
477 10,
478 TaskStatus::Running,
479 Some("仅备份数据,不含 schema/索引/触发器,且不可经 psql 恢复".to_string()),
480 None,
481 None,
482 );
483 let filename = format!("{prefix}_{timestamp}_sqlfallback.sql");
484 let path = backup_path(&filename);
485
486 let client = match crate::db::pool::get_conn().await {
487 Ok(c) => c,
488 Err(e) => {
489 let msg = e.to_string();
490 tasks::update(
491 task_id,
492 "数据库连接失败",
493 100,
494 TaskStatus::Failed,
495 None,
496 Some(msg.clone()),
497 None,
498 );
499 return Err(msg);
500 }
501 };
502
503 let tables: Vec<String> = match client
505 .query(
506 "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
507 &[],
508 )
509 .await
510 {
511 Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(),
512 Err(e) => {
513 let msg = e.to_string();
514 tasks::update(
515 task_id,
516 "读取表清单失败",
517 100,
518 TaskStatus::Failed,
519 None,
520 Some(msg.clone()),
521 None,
522 );
523 return Err(msg);
524 }
525 };
526 let total = tables.len().max(1);
527
528 let mut out = String::new();
529 out.push_str(&format!("{}\n", BACKUP_SIGNATURE));
530 out.push_str(&format!("-- created_at: {}\n", Utc::now()));
531 out.push_str("-- mode: sql-fallback\n\n");
532
533 for (i, table) in tables.iter().enumerate() {
534 out.push_str(&format!("\n-- table: {}\n", table));
535 let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table);
536 match client.copy_out(©_stmt).await {
537 Ok(stream) => {
538 use futures::StreamExt;
539 tokio::pin!(stream);
541 while let Some(chunk) = stream.next().await {
542 if let Ok(bytes) = chunk {
543 out.push_str(&String::from_utf8_lossy(&bytes));
544 }
545 }
546 }
547 Err(e) => {
548 out.push_str(&format!("-- 导出失败: {}\n", e));
549 }
550 }
551 tasks::update(
553 task_id,
554 &format!("导出表 {}/{}", i + 1, total),
555 (10 + (i + 1) as u32 * 90 / total as u32).min(99) as u8,
556 TaskStatus::Running,
557 None,
558 None,
559 None,
560 );
561 }
562
563 if let Err(e) = std::fs::write(&path, out) {
564 let msg = format!("无法写入备份目录: {e}");
565 tasks::update(
566 task_id,
567 "写入备份文件失败",
568 100,
569 TaskStatus::Failed,
570 None,
571 Some(msg.clone()),
572 None,
573 );
574 return Err(msg);
575 }
576 Ok(filename)
577}
578
579#[server(RestoreBackup, "/api")]
581pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ServerFnError> {
582 let _user = get_current_admin_user().await?;
583
584 #[cfg(feature = "server")]
587 {
588 if !confirm {
589 return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
590 }
591 if !is_valid_backup_filename(&filename) {
593 return Err(AppError::BadRequest("无效的文件名".to_string()).into());
594 }
595 let path = backup_path(&filename);
596 if !path.exists() {
597 return Err(AppError::NotFound("备份文件不存在").into());
598 }
599
600 let first_line = read_first_line(&path).unwrap_or_default();
602 if !has_valid_signature(&first_line) {
603 return Err(
604 AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into(),
605 );
606 }
607
608 let task_id = uuid::Uuid::new_v4().to_string();
609 tasks::insert(task_id.clone(), TaskKind::Restore);
610 let tid = task_id.clone();
611 let f = filename;
612 tokio::spawn(async move {
613 run_restore(&tid, &f).await;
614 });
615 Ok(task_id)
616 }
617 #[cfg(not(feature = "server"))]
618 {
619 let _ = (filename, confirm);
621 Ok(String::new())
622 }
623}
624
625#[cfg(feature = "server")]
643fn sql_line_without_eol(mut line: &[u8]) -> &[u8] {
644 if let Some(stripped) = line.strip_suffix(b"\n") {
645 line = stripped;
646 }
647 if let Some(stripped) = line.strip_suffix(b"\r") {
648 line = stripped;
649 }
650 line
651}
652
653#[cfg(feature = "server")]
654fn is_legacy_pg_dump_owner_statement(line: &[u8]) -> bool {
655 const PREFIXES: [&[u8]; 3] = [b"ALTER FUNCTION ", b"ALTER SEQUENCE ", b"ALTER TABLE "];
656 let line = sql_line_without_eol(line);
657 PREFIXES.iter().any(|prefix| line.starts_with(prefix))
658 && line
659 .windows(b" OWNER TO ".len())
660 .any(|window| window == b" OWNER TO ")
661 && line.ends_with(b";")
662}
663
664#[cfg(feature = "server")]
665fn write_owner_neutral_restore_sql(
666 mut input: impl std::io::BufRead,
667 mut output: impl std::io::Write,
668) -> std::io::Result<usize> {
669 let mut line = Vec::new();
670 let mut in_copy_data = false;
671 let mut removed = 0;
672 loop {
673 line.clear();
674 if input.read_until(b'\n', &mut line)? == 0 {
675 break;
676 }
677 let sql_line = sql_line_without_eol(&line);
678 if in_copy_data {
679 output.write_all(&line)?;
680 if sql_line == b"\\." {
681 in_copy_data = false;
682 }
683 continue;
684 }
685 if sql_line.starts_with(b"COPY ") && sql_line.ends_with(b" FROM stdin;") {
686 in_copy_data = true;
687 output.write_all(&line)?;
688 } else if is_legacy_pg_dump_owner_statement(&line) {
689 removed += 1;
690 } else {
691 output.write_all(&line)?;
692 }
693 }
694 output.flush()?;
695 Ok(removed)
696}
697
698#[cfg(feature = "server")]
699struct PreparedRestoreSql {
700 path: PathBuf,
701 removed_owner_statements: usize,
702}
703
704#[cfg(feature = "server")]
705impl PreparedRestoreSql {
706 fn prepare(source: &Path) -> std::io::Result<Self> {
707 let input = std::io::BufReader::new(std::fs::File::open(source)?);
708 let path =
709 std::env::temp_dir().join(format!("yggdrasil-restore-{}.sql", uuid::Uuid::new_v4()));
710 let mut options = std::fs::OpenOptions::new();
711 options.write(true).create_new(true);
712 #[cfg(unix)]
713 {
714 use std::os::unix::fs::OpenOptionsExt;
715 options.mode(0o600);
716 }
717 let output = std::io::BufWriter::new(options.open(&path)?);
718 match write_owner_neutral_restore_sql(input, output) {
719 Ok(removed_owner_statements) => Ok(Self {
720 path,
721 removed_owner_statements,
722 }),
723 Err(error) => {
724 let _ = std::fs::remove_file(&path);
725 Err(error)
726 }
727 }
728 }
729}
730
731#[cfg(feature = "server")]
732impl Drop for PreparedRestoreSql {
733 fn drop(&mut self) {
734 if let Err(error) = std::fs::remove_file(&self.path) {
735 tracing::warn!(
736 path = %self.path.display(),
737 "restore: failed to delete prepared SQL: {error}"
738 );
739 }
740 }
741}
742
743#[cfg(feature = "server")]
744fn psql_restore_command(db_url: &str, path: &Path) -> std::process::Command {
745 let mut command = std::process::Command::new("psql");
746 command
747 .arg(db_url)
748 .arg("--single-transaction")
749 .arg("-v")
750 .arg("ON_ERROR_STOP=1")
751 .arg("-f")
752 .arg(path)
753 .stdout(std::process::Stdio::null())
754 .stderr(std::process::Stdio::piped());
755 command
756}
757
758#[cfg(feature = "server")]
759async fn run_restore(task_id: &str, filename: &str) {
760 let path = backup_path(filename);
761 let db_url = match std::env::var("DATABASE_URL") {
762 Ok(u) if !u.is_empty() => u,
763 _ => {
764 tasks::update(
765 task_id,
766 "DATABASE_URL 未配置",
767 100,
768 TaskStatus::Failed,
769 None,
770 Some("恢复需要 DATABASE_URL".to_string()),
771 None,
772 );
773 return;
774 }
775 };
776 let psql_ok = tokio::task::spawn_blocking(|| {
777 std::process::Command::new("psql")
778 .arg("--version")
779 .output()
780 .is_ok()
781 })
782 .await
783 .unwrap_or(false);
784 if !psql_ok {
785 tasks::update(
786 task_id,
787 "psql 不可用",
788 100,
789 TaskStatus::Failed,
790 None,
791 Some("恢复需要 psql,但当前环境未安装 psql".to_string()),
792 None,
793 );
794 return;
795 }
796 tasks::update(
797 task_id,
798 "正在用 psql 恢复",
799 50,
800 TaskStatus::Running,
801 None,
802 None,
803 None,
804 );
805 let restore_result = tokio::task::spawn_blocking(move || {
808 let prepared = PreparedRestoreSql::prepare(&path)?;
809 if prepared.removed_owner_statements > 0 {
810 tracing::info!(
811 removed = prepared.removed_owner_statements,
812 "restore: removed legacy pg_dump owner statements"
813 );
814 }
815 psql_restore_command(&db_url, &prepared.path).output()
816 })
817 .await
818 .unwrap_or_else(|join_e| Err(std::io::Error::other(join_e.to_string())));
819 match restore_result {
820 Ok(o) if o.status.success() => {
821 crate::cache::invalidate_all_post_caches();
824 crate::cache::invalidate_search_results();
825 crate::ssr_cache::bump_global_generation();
826 tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None);
827 }
828 Ok(o) => {
829 let stderr = String::from_utf8_lossy(&o.stderr).to_string();
830 tasks::update(
831 task_id,
832 "恢复失败",
833 100,
834 TaskStatus::Failed,
835 None,
836 Some(stderr),
837 None,
838 );
839 }
840 Err(e) => {
841 tasks::update(
842 task_id,
843 "psql 启动失败",
844 100,
845 TaskStatus::Failed,
846 None,
847 Some(e.to_string()),
848 None,
849 );
850 }
851 }
852}
853
854#[server(ListBackups, "/api")]
858pub async fn list_backups() -> Result<Vec<BackupInfo>, ServerFnError> {
859 let _user = get_current_admin_user().await?;
860 #[cfg(feature = "server")]
861 {
862 let mut infos: Vec<BackupInfo> = Vec::new();
863 let mut tarballs: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
865 if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
866 for entry in entries.flatten() {
867 let name = entry.file_name().to_string_lossy().to_string();
868 if name.ends_with("_uploads.tar.gz") {
869 if let Ok(meta) = entry.metadata() {
870 tarballs.insert(name, meta.len());
871 }
872 }
873 }
874 }
875 if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
876 for entry in entries.flatten() {
877 let name = entry.file_name().to_string_lossy().to_string();
878 if !name.ends_with(".sql") {
879 continue;
880 }
881 let meta = match entry.metadata() {
882 Ok(m) => m,
883 Err(_) => continue,
884 };
885 let mode = read_first_lines(entry.path(), 3)
888 .map(|lines| parse_backup_mode(&lines.join("\n")))
889 .unwrap_or_else(|_| "unknown".to_string());
890 let created_at = meta
891 .modified()
892 .ok()
893 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
894 .map(|d| {
895 chrono::DateTime::<Utc>::from_timestamp(d.as_secs() as i64, 0)
896 .map(|dt| dt.to_rfc3339())
897 .unwrap_or_default()
898 });
899 let tar_name = uploads_archive_name(&name);
900 let (uploads_filename, uploads_size_bytes) = tarballs
901 .get_key_value(&tar_name)
902 .map(|(k, v)| (Some(k.clone()), Some(*v)))
903 .unwrap_or((None, None));
904 infos.push(BackupInfo {
905 origin: if name.starts_with("auto_") {
906 "auto"
907 } else {
908 "manual"
909 }
910 .to_string(),
911 filename: name,
912 size_bytes: meta.len(),
913 mode,
914 created_at,
915 uploads_filename,
916 uploads_size_bytes,
917 });
918 }
919 }
920 infos.sort_by(|a, b| b.created_at.cmp(&a.created_at));
922 Ok(infos)
923 }
924 #[cfg(not(feature = "server"))]
925 {
926 Ok(vec![])
927 }
928}
929
930#[server(DeleteBackup, "/api")]
932pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> {
933 let _user = get_current_admin_user().await?;
934 #[cfg(feature = "server")]
935 {
936 if !is_valid_backup_filename(&filename) {
937 return Err(AppError::BadRequest("无效的文件名".to_string()).into());
938 }
939 let path = backup_path(&filename);
940 if !path.exists() {
941 return Err(AppError::NotFound("备份文件不存在").into());
942 }
943 std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?;
944 let pair = if filename.ends_with(".sql") {
946 Some(uploads_archive_name(&filename))
947 } else {
948 filename
949 .strip_suffix("_uploads.tar.gz")
950 .map(|stem| format!("{stem}.sql"))
951 };
952 if let Some(pair_name) = pair {
953 match std::fs::remove_file(backup_path(&pair_name)) {
954 Ok(()) | Err(_) => {} }
956 }
957 Ok(())
958 }
959 #[cfg(not(feature = "server"))]
960 {
961 Ok(())
962 }
963}
964
965#[cfg(feature = "server")]
977fn backup_path(filename: &str) -> PathBuf {
978 let filename_is_safe = std::path::Path::new(filename)
980 .components()
981 .all(|c| matches!(c, Component::Normal(_)));
982 if filename_is_safe {
983 let mut p = PathBuf::from(BACKUP_DIR);
984 p.push(filename);
985 p
986 } else {
987 PathBuf::from(BACKUP_DIR)
989 }
990}
991
992#[cfg(feature = "server")]
995fn is_valid_backup_filename(filename: &str) -> bool {
996 regex::Regex::new(FILENAME_RE)
998 .map(|re| re.is_match(filename))
999 .unwrap_or(false)
1000}
1001
1002#[cfg(feature = "server")]
1005pub(crate) fn import_max_bytes() -> u64 {
1006 std::env::var("BACKUP_IMPORT_MAX_MB")
1007 .ok()
1008 .and_then(|s| s.parse::<u64>().ok())
1009 .filter(|mb| *mb > 0)
1010 .unwrap_or(DEFAULT_IMPORT_MAX_MB)
1011 .saturating_mul(1024 * 1024)
1012}
1013
1014#[cfg(feature = "server")]
1017fn sanitize_import_filename(raw: &str) -> Option<String> {
1018 let name = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
1019 if name.len() > 255
1020 || !name.ends_with(".sql")
1021 || name.starts_with('.')
1022 || !is_valid_backup_filename(name)
1023 {
1024 return None;
1025 }
1026 Some(name.to_string())
1027}
1028
1029#[cfg(feature = "server")]
1032fn backup_partition_free_space() -> Option<u64> {
1033 let dir = std::fs::canonicalize(BACKUP_DIR).ok()?;
1034 let disks = sysinfo::Disks::new_with_refreshed_list();
1035 disks
1036 .iter()
1037 .filter(|d| dir.starts_with(d.mount_point()))
1038 .max_by_key(|d| d.mount_point().as_os_str().len())
1039 .map(|d| d.available_space())
1040}
1041
1042#[cfg(feature = "server")]
1046fn parse_backup_mode(content: &str) -> String {
1047 content
1048 .lines()
1049 .find(|l| l.starts_with("-- mode:"))
1050 .map(|l| l.trim_start_matches("-- mode:").trim().to_string())
1051 .filter(|s| !s.is_empty())
1052 .unwrap_or_else(|| "unknown".to_string())
1053}
1054
1055#[cfg(feature = "server")]
1058fn has_valid_signature(content: &str) -> bool {
1059 content
1060 .lines()
1061 .next()
1062 .map(|l| l.trim().contains(BACKUP_SIGNATURE))
1063 .unwrap_or(false)
1064}
1065
1066#[cfg(feature = "server")]
1068fn read_first_line(path: impl AsRef<Path>) -> std::io::Result<String> {
1069 use std::io::BufRead;
1070 let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
1071 let mut line = String::new();
1072 reader.read_line(&mut line)?;
1073 Ok(line)
1074}
1075
1076#[cfg(feature = "server")]
1079fn read_first_lines(path: impl AsRef<Path>, n: usize) -> std::io::Result<Vec<String>> {
1080 use std::io::BufRead;
1081 let reader = std::io::BufReader::new(std::fs::File::open(path)?);
1082 reader.lines().take(n).collect()
1083}
1084
1085#[cfg(feature = "server")]
1095pub async fn import_backup(
1096 connect_info: Option<
1097 axum::extract::Extension<axum::extract::ConnectInfo<std::net::SocketAddr>>,
1098 >,
1099 headers: axum::http::HeaderMap,
1100 mut multipart: axum::extract::Multipart,
1101) -> Result<axum::Json<serde_json::Value>, (axum::http::StatusCode, axum::Json<serde_json::Value>)>
1102{
1103 use crate::api::upload::upload_error;
1104 use axum::http::StatusCode;
1105 use tokio::io::AsyncWriteExt;
1106
1107 let peer = connect_info.map(|axum::extract::Extension(axum::extract::ConnectInfo(addr))| addr);
1109 let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
1110 if let Err(msg) = crate::api::rate_limit::check_upload_limit(&ip) {
1111 return Err(upload_error(StatusCode::TOO_MANY_REQUESTS, msg));
1112 }
1113
1114 let cookie_header = headers
1116 .get("cookie")
1117 .and_then(|h| h.to_str().ok())
1118 .unwrap_or("");
1119 let token = match crate::auth::session::parse_session_token(cookie_header) {
1120 Some(t) => t,
1121 None => return Err(upload_error(StatusCode::UNAUTHORIZED, "未登录")),
1122 };
1123 let user = match crate::api::auth::get_user_by_token(token).await {
1124 Ok(Some(u)) => u,
1125 _ => return Err(upload_error(StatusCode::UNAUTHORIZED, "会话已过期")),
1126 };
1127 if user.role != crate::models::user::UserRole::Admin {
1128 return Err(upload_error(StatusCode::FORBIDDEN, "权限不足"));
1129 }
1130
1131 let max_bytes = import_max_bytes();
1132
1133 let content_length = headers
1135 .get(axum::http::header::CONTENT_LENGTH)
1136 .and_then(|v| v.to_str().ok())
1137 .and_then(|s| s.parse::<u64>().ok());
1138 if let Some(cl) = content_length {
1139 if cl > max_bytes + MULTIPART_FRAME_SLACK {
1140 return Err(upload_error(
1141 StatusCode::PAYLOAD_TOO_LARGE,
1142 "文件超过导入上限",
1143 ));
1144 }
1145 }
1146
1147 let mut field = match multipart.next_field().await {
1149 Ok(Some(f)) => f,
1150 Ok(None) => return Err(upload_error(StatusCode::BAD_REQUEST, "未找到文件")),
1151 Err(e) => {
1152 tracing::error!("backup import multipart error: {e:?}");
1153 return Err(upload_error(StatusCode::BAD_REQUEST, "文件读取失败"));
1154 }
1155 };
1156 let filename = match sanitize_import_filename(field.file_name().unwrap_or_default()) {
1157 Some(n) => n,
1158 None => {
1159 return Err(upload_error(
1160 StatusCode::BAD_REQUEST,
1161 "文件名不合法:仅接受以 .sql 结尾的备份文件名(字母/数字/下划线/点/连字符)",
1162 ))
1163 }
1164 };
1165
1166 if let Err(e) = std::fs::create_dir_all(BACKUP_DIR) {
1168 tracing::error!("backup import: create dir failed: {e}");
1169 return Err(upload_error(
1170 StatusCode::INTERNAL_SERVER_ERROR,
1171 "无法创建备份目录",
1172 ));
1173 }
1174 let final_path = backup_path(&filename);
1175 if final_path.exists() {
1176 return Err(upload_error(StatusCode::CONFLICT, "已存在同名备份文件"));
1177 }
1178
1179 if let (Some(cl), Some(free)) = (content_length, backup_partition_free_space()) {
1181 if cl > free {
1182 return Err(upload_error(
1183 StatusCode::INSUFFICIENT_STORAGE,
1184 "磁盘空间不足",
1185 ));
1186 }
1187 }
1188
1189 let tmp_name = format!(
1191 ".import-{}-{}.tmp",
1192 std::process::id(),
1193 std::time::SystemTime::now()
1194 .duration_since(std::time::UNIX_EPOCH)
1195 .map(|d| d.as_nanos())
1196 .unwrap_or(0)
1197 );
1198 let tmp_path = backup_path(&tmp_name);
1199 let mut out = match tokio::fs::File::create(&tmp_path).await {
1200 Ok(f) => f,
1201 Err(e) => {
1202 tracing::error!("backup import: create tmp failed: {e}");
1203 return Err(upload_error(
1204 StatusCode::INTERNAL_SERVER_ERROR,
1205 "无法写入备份目录",
1206 ));
1207 }
1208 };
1209 let mut written: u64 = 0;
1210 let stream_result: Result<(), (StatusCode, &'static str)> = loop {
1211 match field.chunk().await {
1212 Ok(Some(chunk)) => {
1213 written += chunk.len() as u64;
1214 if written > max_bytes {
1215 break Err((StatusCode::PAYLOAD_TOO_LARGE, "文件超过导入上限"));
1216 }
1217 if let Err(e) = out.write_all(&chunk).await {
1218 tracing::error!("backup import: write failed: {e}");
1219 break Err((
1220 StatusCode::INTERNAL_SERVER_ERROR,
1221 "写入失败(磁盘可能已满)",
1222 ));
1223 }
1224 }
1225 Ok(None) => break Ok(()),
1226 Err(e) => {
1227 tracing::error!("backup import: chunk error: {e:?}");
1228 break Err((StatusCode::BAD_REQUEST, "文件读取失败"));
1229 }
1230 }
1231 };
1232 drop(out);
1233 if let Err((status, msg)) = stream_result {
1234 let _ = std::fs::remove_file(&tmp_path);
1235 return Err(upload_error(status, msg));
1236 }
1237
1238 let first_line = read_first_line(&tmp_path).unwrap_or_default();
1240 if !has_valid_signature(&first_line) {
1241 let _ = std::fs::remove_file(&tmp_path);
1242 return Err(upload_error(
1243 StatusCode::BAD_REQUEST,
1244 "非本系统生成的备份文件,拒绝导入",
1245 ));
1246 }
1247
1248 if final_path.exists() {
1250 let _ = std::fs::remove_file(&tmp_path);
1251 return Err(upload_error(StatusCode::CONFLICT, "已存在同名备份文件"));
1252 }
1253 if let Err(e) = std::fs::rename(&tmp_path, &final_path) {
1254 let _ = std::fs::remove_file(&tmp_path);
1255 tracing::error!("backup import: rename failed: {e}");
1256 return Err(upload_error(StatusCode::INTERNAL_SERVER_ERROR, "入库失败"));
1257 }
1258
1259 tracing::info!(
1260 operator = %user.username,
1261 filename = %filename,
1262 size_bytes = written,
1263 "备份导入成功"
1264 );
1265 Ok(axum::Json(
1266 serde_json::json!({ "success": true, "filename": filename }),
1267 ))
1268}
1269
1270#[cfg(feature = "server")]
1273pub async fn download_backup(
1274 axum::extract::Path(filename): axum::extract::Path<String>,
1275 headers: axum::http::HeaderMap,
1276) -> Result<impl axum::response::IntoResponse, (axum::http::StatusCode, String)> {
1277 use axum::http::{header, StatusCode};
1278
1279 let cookie_header = headers
1281 .get("cookie")
1282 .and_then(|h| h.to_str().ok())
1283 .unwrap_or("");
1284 let token = crate::auth::session::parse_session_token(cookie_header).map(str::to_string);
1285 let token = match token {
1286 Some(t) => t,
1287 None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
1288 };
1289 let user = match crate::api::auth::get_user_by_token(&token).await {
1290 Ok(Some(u)) => u,
1291 _ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
1292 };
1293 if user.role != crate::models::user::UserRole::Admin {
1294 return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
1295 }
1296
1297 if !is_valid_backup_filename(&filename) {
1299 return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string()));
1300 }
1301 let path = backup_path(&filename);
1302 let bytes = tokio::fs::read(&path)
1303 .await
1304 .map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?;
1305 let disposition = format!("attachment; filename=\"{}\"", filename);
1306 let content_type = if filename.ends_with(".tar.gz") {
1307 "application/gzip"
1308 } else {
1309 "application/sql; charset=utf-8"
1310 };
1311 Ok((
1312 StatusCode::OK,
1313 [
1314 (
1315 header::CONTENT_TYPE,
1316 axum::http::HeaderValue::from_static(content_type),
1317 ),
1318 (
1319 header::CONTENT_DISPOSITION,
1320 axum::http::HeaderValue::from_str(&disposition)
1321 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")),
1322 ),
1323 ],
1324 axum::body::Body::from(bytes),
1325 ))
1326}
1327
1328#[cfg(all(test, feature = "server"))]
1329mod tests {
1330 use super::*;
1331
1332 #[test]
1335 fn filename_accepts_normal_names() {
1336 for name in [
1337 "backup_20260702_120000.sql",
1338 "backup_20260702_120000_sqlfallback.sql",
1339 "a.sql",
1340 "A-B_C.123",
1341 ] {
1342 assert!(is_valid_backup_filename(name), "正常文件名应通过: {name}");
1343 }
1344 }
1345
1346 #[test]
1347 fn filename_rejects_path_traversal() {
1348 for evil in [
1350 "../etc/passwd",
1351 "..\\windows\\win.ini",
1352 "/etc/passwd",
1353 "a/../../b",
1354 "backup.sql/../../etc",
1355 ] {
1356 assert!(!is_valid_backup_filename(evil), "路径穿越应被拒: {evil}");
1357 }
1358 }
1359
1360 #[test]
1361 fn filename_rejects_spaces_and_special_chars() {
1362 for evil in [
1364 "backup with space.sql",
1365 "备份.sql",
1366 "a;rm -rf.sql",
1367 r"a\$b.sql",
1368 "a`b`.sql",
1369 "",
1370 ] {
1371 assert!(!is_valid_backup_filename(evil), "特殊字符应被拒: {evil:?}");
1372 }
1373 }
1374
1375 #[test]
1378 fn backup_path_stays_in_backup_dir_for_normal_name() {
1379 let p = backup_path("backup_20260702.sql");
1380 assert!(p.starts_with(BACKUP_DIR), "应在 {BACKUP_DIR}/ 下");
1381 assert_eq!(
1382 p.file_name().and_then(|n| n.to_str()),
1383 Some("backup_20260702.sql")
1384 );
1385 }
1386
1387 #[test]
1388 fn backup_path_collapses_traversal_to_backup_dir() {
1389 for evil in ["../etc/passwd", "../../etc/shadow"] {
1392 let p = backup_path(evil);
1393 assert_eq!(
1395 p,
1396 PathBuf::from(BACKUP_DIR),
1397 "穿越应被规约回 {BACKUP_DIR}: {evil}"
1398 );
1399 }
1400 }
1401
1402 #[test]
1403 fn backup_path_rejects_absolute_path() {
1404 let p = backup_path("/etc/passwd");
1406 assert_eq!(p, PathBuf::from(BACKUP_DIR));
1407 }
1408
1409 #[test]
1412 fn signature_matches_exact_header() {
1413 let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\nSELECT 1;\n";
1414 assert!(has_valid_signature(content));
1415 }
1416
1417 #[test]
1418 fn signature_matches_with_leading_whitespace() {
1419 let content = " -- YGGDRASIL BACKUP v1\nrest\n";
1421 assert!(has_valid_signature(content));
1422 }
1423
1424 #[test]
1425 fn signature_rejects_non_system_file() {
1426 let content = "SELECT * FROM users;\n-- YGGDRASIL BACKUP v1\n";
1428 assert!(!has_valid_signature(content));
1430 }
1431
1432 #[test]
1433 fn signature_rejects_empty_and_garbage() {
1434 assert!(!has_valid_signature(""));
1435 assert!(!has_valid_signature("garbage\n"));
1436 assert!(!has_valid_signature("\n\n-- YGGDRASIL BACKUP v1"));
1437 }
1438
1439 #[test]
1442 fn parse_mode_pg_dump() {
1443 let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\n...\n";
1444 assert_eq!(parse_backup_mode(content), "pg_dump");
1445 }
1446
1447 #[test]
1448 fn parse_mode_sql_fallback() {
1449 let content = "-- YGGDRASIL BACKUP v1\n-- mode: sql-fallback\n\n-- table: posts\n";
1450 assert_eq!(parse_backup_mode(content), "sql-fallback");
1451 }
1452
1453 #[test]
1454 fn parse_mode_unknown_when_absent() {
1455 let content = "-- YGGDRASIL BACKUP v1\nSELECT 1;\n";
1456 assert_eq!(parse_backup_mode(content), "unknown");
1457 }
1458
1459 #[test]
1460 fn parse_mode_unknown_when_empty_value() {
1461 let content = "-- mode:\nrest\n";
1463 assert_eq!(parse_backup_mode(content), "unknown");
1464 }
1465
1466 #[test]
1467 fn parse_mode_only_matches_first_occurrence() {
1468 let content = "-- mode: pg_dump\n-- mode: sql-fallback\n";
1470 assert_eq!(parse_backup_mode(content), "pg_dump");
1471 }
1472
1473 #[test]
1476 fn uploads_archive_name_pairs_with_sql() {
1477 assert_eq!(
1478 uploads_archive_name("backup_20260810_040000.sql"),
1479 "backup_20260810_040000_uploads.tar.gz"
1480 );
1481 assert_eq!(
1482 uploads_archive_name("auto_20260810_040000_sqlfallback.sql"),
1483 "auto_20260810_040000_sqlfallback_uploads.tar.gz"
1484 );
1485 }
1486
1487 #[test]
1490 fn rotation_keeps_newest_and_deletes_oldest() {
1491 let names: Vec<String> = [
1492 "auto_20260808_040000.sql",
1493 "auto_20260810_040000.sql",
1494 "auto_20260809_040000.sql",
1495 ]
1496 .iter()
1497 .map(|s| s.to_string())
1498 .collect();
1499 assert_eq!(
1501 select_expired_auto_backups(&names, 2),
1502 vec!["auto_20260808_040000.sql".to_string()]
1503 );
1504 }
1505
1506 #[test]
1507 fn rotation_ignores_manual_backups_and_tarballs() {
1508 let names: Vec<String> = [
1509 "backup_20260101_000000.sql", "auto_20260808_040000_uploads.tar.gz", "auto_20260808_040000.sql",
1512 "auto_20260809_040000.sql",
1513 ]
1514 .iter()
1515 .map(|s| s.to_string())
1516 .collect();
1517 assert!(select_expired_auto_backups(&names, 2).is_empty());
1518 assert_eq!(
1519 select_expired_auto_backups(&names, 1),
1520 vec!["auto_20260808_040000.sql".to_string()]
1521 );
1522 }
1523
1524 #[test]
1525 fn rotation_empty_and_exact_keep() {
1526 assert!(select_expired_auto_backups(&[], 5).is_empty());
1527 let names: Vec<String> = ["auto_20260808_040000.sql"]
1528 .iter()
1529 .map(|s| s.to_string())
1530 .collect();
1531 assert!(select_expired_auto_backups(&names, 1).is_empty());
1532 assert_eq!(select_expired_auto_backups(&names, 0).len(), 1);
1534 }
1535
1536 #[test]
1539 fn tarball_excludes_cache_and_gitkeep() {
1540 let nanos = std::time::SystemTime::now()
1541 .duration_since(std::time::UNIX_EPOCH)
1542 .expect("系统时间必然晚于 UNIX_EPOCH")
1543 .as_nanos();
1544 let dir = std::env::temp_dir().join(format!(
1545 "yggdrasil_backup_tar_test_{}_{}",
1546 nanos,
1547 std::process::id()
1548 ));
1549 let uploads = dir.join("uploads");
1550 std::fs::create_dir_all(uploads.join("2026")).expect("创建测试目录");
1551 std::fs::create_dir_all(uploads.join(".cache")).expect("创建测试目录");
1552 std::fs::write(uploads.join("2026/pic.webp"), b"img").expect("写测试文件");
1553 std::fs::write(uploads.join(".cache/x.webp"), b"cache").expect("写测试文件");
1554 std::fs::write(uploads.join(".gitkeep"), b"").expect("写测试文件");
1555
1556 let out = dir.join("out.tar.gz");
1557 create_uploads_tarball(&uploads, &out).expect("打包应成功");
1558
1559 let file = std::fs::File::open(&out).expect("打开打包产物");
1560 let gz = flate2::read::GzDecoder::new(file);
1561 let mut archive = tar::Archive::new(gz);
1562 let entries: Vec<String> = archive
1563 .entries()
1564 .expect("读取 tar 条目")
1565 .map(|e| {
1566 e.expect("tar 条目有效")
1567 .path()
1568 .expect("路径有效")
1569 .to_string_lossy()
1570 .to_string()
1571 })
1572 .collect();
1573 assert!(
1574 entries.iter().any(|p| p.contains("2026/pic.webp")),
1575 "应包含素材文件: {entries:?}"
1576 );
1577 assert!(
1578 !entries
1579 .iter()
1580 .any(|p| p.contains(".cache") || p.contains(".gitkeep")),
1581 "应排除 .cache 与 .gitkeep: {entries:?}"
1582 );
1583 std::fs::remove_dir_all(&dir).expect("清理测试目录");
1584 }
1585
1586 #[test]
1589 fn import_filename_accepts_plain_backup_name() {
1590 assert_eq!(
1591 sanitize_import_filename("backup_20260816_200000.sql"),
1592 Some("backup_20260816_200000.sql".to_string())
1593 );
1594 let max_ok = format!("{}.sql", "a".repeat(251));
1596 assert!(sanitize_import_filename(&max_ok).is_some());
1597 }
1598
1599 #[test]
1600 fn import_filename_strips_path_components() {
1601 assert_eq!(
1603 sanitize_import_filename("C:\\fakepath\\auto_20260816_200000.sql"),
1604 Some("auto_20260816_200000.sql".to_string())
1605 );
1606 assert_eq!(
1607 sanitize_import_filename("/tmp/x/backup_1.sql"),
1608 Some("backup_1.sql".to_string())
1609 );
1610 }
1611
1612 #[test]
1613 fn import_filename_rejects_non_sql_and_hidden() {
1614 assert_eq!(sanitize_import_filename("x_uploads.tar.gz"), None);
1616 assert_eq!(sanitize_import_filename("noext"), None);
1617 assert_eq!(sanitize_import_filename(".hidden.sql"), None);
1619 assert_eq!(sanitize_import_filename(".sql"), None);
1620 assert_eq!(sanitize_import_filename(""), None);
1621 }
1622
1623 #[test]
1624 fn import_filename_rejects_illegal_chars_and_overlong() {
1625 assert_eq!(sanitize_import_filename("带中文.sql"), None);
1626 assert_eq!(sanitize_import_filename("has space.sql"), None);
1627 let long = format!("{}.sql", "a".repeat(252));
1628 assert_eq!(sanitize_import_filename(&long), None);
1629 }
1630 #[test]
1631 fn pg_dump_is_owner_and_acl_neutral() {
1632 let command = pg_dump_command("<DATABASE_URL>");
1633 let args: Vec<_> = command
1634 .get_args()
1635 .map(|arg| arg.to_string_lossy().into_owned())
1636 .collect();
1637 assert!(args.iter().any(|arg| arg == "--no-owner"), "{args:?}");
1638 assert!(args.iter().any(|arg| arg == "--no-privileges"), "{args:?}");
1639 }
1640
1641 #[test]
1642 fn psql_restore_is_atomic() {
1643 let command = psql_restore_command("<DATABASE_URL>", Path::new("backup.sql"));
1644 let args: Vec<_> = command
1645 .get_args()
1646 .map(|arg| arg.to_string_lossy().into_owned())
1647 .collect();
1648 assert!(
1649 args.iter().any(|arg| arg == "--single-transaction"),
1650 "{args:?}"
1651 );
1652 }
1653
1654 #[test]
1655 fn legacy_owner_statements_are_removed_without_touching_copy_data() {
1656 let sql = concat!(
1657 "-- YGGDRASIL BACKUP v1\n",
1658 "ALTER FUNCTION public.f() OWNER TO yggdrasil;\n",
1659 "COPY public.lines (value) FROM stdin;\n",
1660 "ALTER TABLE public.literal OWNER TO text;\n",
1661 "\\.\n",
1662 "ALTER TABLE public.users OWNER TO yggdrasil;\n",
1663 "ALTER SEQUENCE public.users_id_seq OWNER TO yggdrasil;\n",
1664 "ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;\n",
1665 );
1666 let mut output = Vec::new();
1667 let removed = write_owner_neutral_restore_sql(std::io::Cursor::new(sql), &mut output)
1668 .expect("过滤测试 SQL 应成功");
1669 let output = String::from_utf8(output).expect("测试 SQL 是 UTF-8");
1670
1671 assert_eq!(removed, 3);
1672 assert!(
1673 output.contains("ALTER TABLE public.literal OWNER TO text;"),
1674 "COPY 数据不得被当成 SQL 删除: {output}"
1675 );
1676 assert!(!output.contains("OWNER TO yggdrasil"), "{output}");
1677 assert!(
1678 output.contains("ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;"),
1679 "{output}"
1680 );
1681 }
1682}