Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 102 additions & 5 deletions server/internal/backup/file_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package backup
import (
"archive/tar"
"context"
"encoding/json"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -52,6 +53,20 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
defer tw.Close()

excludes := normalizeExcludePatterns(task.ExcludePatterns)

// 差异备份:基于上次全量清单仅打包新增/变更条目并记录删除;
// 全量备份:记录完整清单(manifest)供后续差异比对。
differential := task.Differential && len(task.BaseManifest.Entries) > 0
baseIndex := map[string]ManifestEntry{}
seen := map[string]struct{}{}
var manifest *Manifest
if differential {
baseIndex = task.BaseManifest.index()
writer.WriteLine(fmt.Sprintf("差异备份模式:基线含 %d 个条目", len(baseIndex)))
} else {
manifest = &Manifest{Entries: make([]ManifestEntry, 0)}
}

totalFileCount := 0
totalDirCount := 0

Expand Down Expand Up @@ -88,6 +103,16 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
return nil
}

entry := entryFromInfo(archiveName, currentInfo)
if differential {
seen[entry.Path] = struct{}{}
if !changedSince(baseIndex, entry) {
return nil // 自全量以来未变更,跳过
}
} else {
manifest.Entries = append(manifest.Entries, entry)
}

if currentInfo.IsDir() {
dirCount++
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
Expand Down Expand Up @@ -130,10 +155,16 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
totalDirCount += dirCount
}

if len(sourcePaths) > 1 {
if differential {
deletions := deletedPaths(baseIndex, seen)
if err := writeDeletionsEntry(tw, deletions); err != nil {
return nil, err
}
writer.WriteLine(fmt.Sprintf("差异备份完成(%d 个目录、%d 个文件变更,删除 %d 项)", totalDirCount, totalFileCount, len(deletions)))
} else if len(sourcePaths) > 1 {
writer.WriteLine(fmt.Sprintf("全部源路径打包完成(共 %d 个目录,%d 个文件)", totalDirCount, totalFileCount))
}
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir, Manifest: manifest}, nil
}

func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
Expand All @@ -151,6 +182,7 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
if err := os.MkdirAll(targetParent, 0o755); err != nil {
return fmt.Errorf("create restore parent: %w", err)
}
var pendingDeletions []string
tr := tar.NewReader(artifactFile)
for {
header, err := tr.Next()
Expand All @@ -160,13 +192,23 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
// 差异归档的删除清单不落地,留待提取完成后统一应用(避免被同批新增条目误删)。
if header.Name == deletionsEntryName {
data, readErr := io.ReadAll(tr)
if readErr != nil {
return fmt.Errorf("read deletions entry: %w", readErr)
}
if jsonErr := json.Unmarshal(data, &pendingDeletions); jsonErr != nil {
return fmt.Errorf("parse deletions entry: %w", jsonErr)
}
continue
}
cleanName := path.Clean(strings.TrimSpace(header.Name))
if cleanName == "." || cleanName == "" {
continue
}
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(cleanName)))
parentWithSep := filepath.Clean(targetParent) + string(filepath.Separator)
if targetPath != filepath.Clean(targetParent) && !strings.HasPrefix(targetPath, parentWithSep) {
targetPath, ok := resolveWithinParent(targetParent, cleanName)
if !ok {
return fmt.Errorf("tar entry escapes restore path")
}
switch header.Typeflag {
Expand All @@ -191,10 +233,65 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
}
}
}
if err := applyDeletions(targetParent, pendingDeletions, writer); err != nil {
return err
}
writer.WriteLine("文件恢复完成")
return nil
}

// resolveWithinParent 将归档相对名安全解析为 targetParent 下的绝对路径;
// 越界(路径穿越)时返回 ok=false。提取与删除共用此校验,杜绝逃逸。
func resolveWithinParent(targetParent, name string) (string, bool) {
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(name)))
cleanParent := filepath.Clean(targetParent)
if targetPath == cleanParent {
return targetPath, true
}
if !strings.HasPrefix(targetPath, cleanParent+string(filepath.Separator)) {
return "", false
}
return targetPath, true
}

// writeDeletionsEntry 将差异备份的删除路径列表写入归档特殊条目。
func writeDeletionsEntry(tw *tar.Writer, deletions []string) error {
payload, err := json.Marshal(deletions)
if err != nil {
return fmt.Errorf("marshal deletions: %w", err)
}
header := &tar.Header{Name: deletionsEntryName, Mode: 0o600, Size: int64(len(payload)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write deletions header: %w", err)
}
if _, err := tw.Write(payload); err != nil {
return fmt.Errorf("write deletions body: %w", err)
}
return nil
}

// applyDeletions 在基线恢复之上删除差异归档记录的路径(仅差异备份恢复时存在)。
// 每个路径经 resolveWithinParent 校验,越界即报错;目标不存在视为已删除。
func applyDeletions(targetParent string, deletions []string, writer LogWriter) error {
for _, name := range deletions {
clean := path.Clean(strings.TrimSpace(name))
if clean == "." || clean == "" {
continue
}
targetPath, ok := resolveWithinParent(targetParent, clean)
if !ok {
return fmt.Errorf("deletion entry escapes restore path")
}
if err := os.RemoveAll(targetPath); err != nil {
return fmt.Errorf("apply deletion %s: %w", clean, err)
}
}
if len(deletions) > 0 {
writer.WriteLine(fmt.Sprintf("已应用差异删除 %d 项", len(deletions)))
}
return nil
}

func normalizeExcludePatterns(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
Expand Down
141 changes: 141 additions & 0 deletions server/internal/backup/file_runner_diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package backup

import (
"archive/tar"
"context"
"io"
"os"
"path/filepath"
"testing"
)

func diffWrite(t *testing.T, p, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(p), err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}

func diffAssertContent(t *testing.T, p, want string) {
t.Helper()
got, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read %s: %v", p, err)
}
if string(got) != want {
t.Fatalf("%s content = %q, want %q", p, string(got), want)
}
}

func diffAssertAbsent(t *testing.T, p string) {
t.Helper()
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("expected %s to be absent, stat err=%v", p, err)
}
}

func diffArchiveNames(t *testing.T, artifactPath string) map[string]bool {
t.Helper()
f, err := os.Open(artifactPath)
if err != nil {
t.Fatalf("open artifact: %v", err)
}
defer f.Close()
names := map[string]bool{}
tr := tar.NewReader(f)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read tar: %v", err)
}
names[h.Name] = true
}
return names
}

// TestFileRunnerDifferentialRoundTrip 验证差异备份的端到端正确性:
// 全量 → 修改源(变更/删除/新增)→ 差异 → 链式恢复(全量+差异)→ 结果与修改后源一致。
func TestFileRunnerDifferentialRoundTrip(t *testing.T) {
work := t.TempDir()
src := filepath.Join(work, "src")
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
diffWrite(t, filepath.Join(src, "b.txt"), "bravo")
diffWrite(t, filepath.Join(src, "sub", "c.txt"), "charlie")

runner := NewFileRunner()

full, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir()}, NopLogWriter{})
if err != nil {
t.Fatalf("full Run: %v", err)
}
if full.Manifest == nil || len(full.Manifest.Entries) == 0 {
t.Fatalf("full backup must produce a manifest, got %#v", full.Manifest)
}

// 变更 a.txt(内容变长 → size 差异必被检出)、删除 b.txt、新增 d.txt;sub/c.txt 不变
diffWrite(t, filepath.Join(src, "a.txt"), "ALPHA-modified-and-longer")
if err := os.Remove(filepath.Join(src, "b.txt")); err != nil {
t.Fatalf("remove b.txt: %v", err)
}
diffWrite(t, filepath.Join(src, "d.txt"), "delta")

diff, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir(), Differential: true, BaseManifest: *full.Manifest}, NopLogWriter{})
if err != nil {
t.Fatalf("differential Run: %v", err)
}
if diff.Manifest != nil {
t.Fatalf("differential backup must not produce a manifest")
}

// 差异归档应包含变更/新增条目与删除清单,但不含未变更的 sub/c.txt
names := diffArchiveNames(t, diff.ArtifactPath)
if !names["src/a.txt"] || !names["src/d.txt"] {
t.Fatalf("differential archive missing changed/new entries: %v", names)
}
if names["src/sub/c.txt"] {
t.Fatalf("differential archive should not contain unchanged file sub/c.txt")
}
if !names[deletionsEntryName] {
t.Fatalf("differential archive missing deletions entry: %v", names)
}

// 链式恢复到全新目标
restoreRoot := t.TempDir()
restoreSrc := filepath.Join(restoreRoot, "src")
restoreTask := TaskSpec{Name: "diff", Type: "file", SourcePath: restoreSrc}
if err := runner.Restore(context.Background(), restoreTask, full.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("restore full: %v", err)
}
if err := runner.Restore(context.Background(), restoreTask, diff.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("restore differential: %v", err)
}

diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "ALPHA-modified-and-longer")
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie")
diffAssertContent(t, filepath.Join(restoreSrc, "d.txt"), "delta")
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt"))
}

// TestFileRunnerDifferentialWithoutBaseIsFull 验证无基线时差异请求回退为全量(产出清单、含全部文件)。
func TestFileRunnerDifferentialWithoutBaseIsFull(t *testing.T) {
src := filepath.Join(t.TempDir(), "src")
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")

runner := NewFileRunner()
res, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir(), Differential: true}, NopLogWriter{})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Manifest == nil {
t.Fatalf("differential without base must fall back to full and produce a manifest")
}
if names := diffArchiveNames(t, res.ArtifactPath); !names["src/a.txt"] || names[deletionsEntryName] {
t.Fatalf("fallback-full archive unexpected: %v", names)
}
}
Loading
Loading