mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-04 23:16:41 +08:00
feat(backup): 新增按需(选择性)文件恢复 (#91)
在内容浏览基础上支持仅恢复勾选的文件/目录到原位置。FileRunner.Restore 按选中集合过滤提取与删除;RestoreService.StartSelective(Start 委托,零破坏);恢复端点接受可选 selectedPaths;前端内容弹窗支持勾选恢复。
This commit is contained in:
@@ -207,6 +207,10 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
||||
if cleanName == "." || cleanName == "" {
|
||||
continue
|
||||
}
|
||||
// 选择性恢复:仅提取被选中的文件/目录(及其子项)。
|
||||
if len(task.SelectedPaths) > 0 && !pathSelected(cleanName, task.SelectedPaths) {
|
||||
continue
|
||||
}
|
||||
targetPath, ok := resolveWithinParent(targetParent, cleanName)
|
||||
if !ok {
|
||||
return fmt.Errorf("tar entry escapes restore path")
|
||||
@@ -233,6 +237,10 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
||||
}
|
||||
}
|
||||
}
|
||||
// 选择性恢复时仅对选中范围应用删除,避免误删未选中的文件。
|
||||
if len(task.SelectedPaths) > 0 {
|
||||
pendingDeletions = filterSelectedPaths(pendingDeletions, task.SelectedPaths)
|
||||
}
|
||||
if err := applyDeletions(targetParent, pendingDeletions, writer); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -240,6 +248,31 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathSelected 判断归档条目名是否落在选中集合内(精确匹配或位于选中目录之下)。
|
||||
func pathSelected(name string, selected []string) bool {
|
||||
for _, sel := range selected {
|
||||
clean := path.Clean(strings.TrimSpace(sel))
|
||||
if clean == "" || clean == "." {
|
||||
continue
|
||||
}
|
||||
if name == clean || strings.HasPrefix(name, clean+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filterSelectedPaths 仅保留落在选中集合内的路径。
|
||||
func filterSelectedPaths(paths []string, selected []string) []string {
|
||||
filtered := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if pathSelected(path.Clean(strings.TrimSpace(p)), selected) {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// resolveWithinParent 将归档相对名安全解析为 targetParent 下的绝对路径;
|
||||
// 越界(路径穿越)时返回 ok=false。提取与删除共用此校验,杜绝逃逸。
|
||||
func resolveWithinParent(targetParent, name string) (string, bool) {
|
||||
|
||||
@@ -122,6 +122,47 @@ func TestFileRunnerDifferentialRoundTrip(t *testing.T) {
|
||||
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt"))
|
||||
}
|
||||
|
||||
func TestPathSelected(t *testing.T) {
|
||||
sel := []string{"src/a.txt", "src/sub"}
|
||||
cases := map[string]bool{
|
||||
"src/a.txt": true,
|
||||
"src/sub": true,
|
||||
"src/sub/c.txt": true, // 选中目录下的子项
|
||||
"src/b.txt": false, // 未选中文件
|
||||
"src/subother": false, // 前缀相近但非子项,不应误判
|
||||
}
|
||||
for name, want := range cases {
|
||||
if got := pathSelected(name, sel); got != want {
|
||||
t.Errorf("pathSelected(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRunnerSelectiveRestore 验证按需恢复:仅选中的文件与目录被还原,未选中的文件不出现。
|
||||
func TestFileRunnerSelectiveRestore(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: "sel", Type: "file", SourcePath: src, TempDir: t.TempDir()}, NopLogWriter{})
|
||||
if err != nil {
|
||||
t.Fatalf("full Run: %v", err)
|
||||
}
|
||||
|
||||
restoreRoot := t.TempDir()
|
||||
restoreSrc := filepath.Join(restoreRoot, "src")
|
||||
task := TaskSpec{Name: "sel", Type: "file", SourcePath: restoreSrc, SelectedPaths: []string{"src/a.txt", "src/sub"}}
|
||||
if err := runner.Restore(context.Background(), task, full.ArtifactPath, NopLogWriter{}); err != nil {
|
||||
t.Fatalf("selective Restore: %v", err)
|
||||
}
|
||||
diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "alpha")
|
||||
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie") // 选中目录 → 子项一并恢复
|
||||
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) // 未选中 → 不恢复
|
||||
}
|
||||
|
||||
// TestFileRunnerDifferentialWithoutBaseIsFull 验证无基线时差异请求回退为全量(产出清单、含全部文件)。
|
||||
func TestFileRunnerDifferentialWithoutBaseIsFull(t *testing.T) {
|
||||
src := filepath.Join(t.TempDir(), "src")
|
||||
|
||||
@@ -40,6 +40,8 @@ type TaskSpec struct {
|
||||
// 并记录被删除的路径。仅文件类型任务支持;BaseManifest 为空时回退为全量。
|
||||
Differential bool
|
||||
BaseManifest Manifest
|
||||
// SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。
|
||||
SelectedPaths []string
|
||||
}
|
||||
|
||||
type RunResult struct {
|
||||
|
||||
Reference in New Issue
Block a user