From ca7c2786d8895a57bcd9cda78a21071001fe2414 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 14 Sep 2026 21:07:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=AF=81=E4=B9=A6=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E7=9B=AE=E5=BD=95=E5=9B=9E=E6=BB=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/cli/backup_restore.go | 115 +++++++++++++++----- backend/internal/cli/backup_restore_test.go | 60 +++++++++- 2 files changed, 146 insertions(+), 29 deletions(-) diff --git a/backend/internal/cli/backup_restore.go b/backend/internal/cli/backup_restore.go index 9c96bfb..b7c5b39 100644 --- a/backend/internal/cli/backup_restore.go +++ b/backend/internal/cli/backup_restore.go @@ -404,48 +404,113 @@ func removeDirectoryWithRollback(target, timestamp string) (func() error, error) if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return nil, errors.New("证书恢复目标必须是普通目录") } - backupPath := target + ".before-restore-" + timestamp - if err := os.Rename(target, backupPath); err != nil { - return nil, fmt.Errorf("保留现有证书目录失败:%w", err) - } - return func() error { return os.Rename(backupPath, target) }, nil + return restoreDirectoryContentsWithRollback("", target, timestamp) } func restoreDirectoryWithRollback(source, target, timestamp string) (func() error, error) { if pathsOverlap(source, target) { return nil, errors.New("证书恢复源目录和目标目录不能互相包含") } - if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { - return nil, fmt.Errorf("创建证书恢复父目录失败:%w", err) - } - backupPath := "" + return restoreDirectoryContentsWithRollback(source, target, timestamp) +} + +func restoreDirectoryContentsWithRollback(source, target, timestamp string) (func() error, error) { + createdTarget := false if info, err := os.Lstat(target); err == nil { if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return nil, errors.New("证书恢复目标必须是普通目录") } - backupPath = target + ".before-restore-" + timestamp - if err := os.Rename(target, backupPath); err != nil { - return nil, fmt.Errorf("保留现有证书目录失败:%w", err) + } else if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { + return nil, fmt.Errorf("创建证书恢复父目录失败:%w", err) } - } else if !errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(target, 0o750); err != nil { + return nil, fmt.Errorf("创建证书恢复父目录失败:%w", err) + } + createdTarget = true + } else { return nil, fmt.Errorf("检查证书恢复目标失败:%w", err) } - if err := copyDirectoryExclusive(source, target); err != nil { - _ = os.RemoveAll(target) - if backupPath != "" { - _ = os.Rename(backupPath, target) - } - return nil, fmt.Errorf("恢复证书目录失败:%w", err) + + transaction, err := os.MkdirTemp(target, ".scert-restore-") + if err != nil { + return nil, fmt.Errorf("保留现有证书目录失败:%w", err) } - return func() error { - if err := os.RemoveAll(target); err != nil { + oldDirectory := filepath.Join(transaction, "old") + if err := os.Mkdir(oldDirectory, 0o700); err != nil { + _ = os.RemoveAll(transaction) + return nil, fmt.Errorf("保留现有证书目录失败:%w", err) + } + + rollback := func() error { + var rollbackErr error + rollbackErr = errors.Join(rollbackErr, clearDirectoryChildren(target, filepath.Base(transaction))) + rollbackErr = errors.Join(rollbackErr, moveDirectoryChildren(oldDirectory, target)) + if createdTarget { + if removeErr := os.Remove(target); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + rollbackErr = errors.Join(rollbackErr, removeErr) + } + } + rollbackErr = errors.Join(rollbackErr, os.RemoveAll(transaction)) + return rollbackErr + } + + if err := moveDirectoryChildren(target, oldDirectory, filepath.Base(transaction)); err != nil { + return nil, errors.Join(fmt.Errorf("保留现有证书目录失败:%w", err), rollback()) + } + + if source != "" { + newDirectory := filepath.Join(transaction, "new") + if err := copyDirectoryExclusive(source, newDirectory); err != nil { + return nil, errors.Join(fmt.Errorf("恢复证书目录失败:%w", err), rollback()) + } + if err := moveDirectoryChildren(newDirectory, target); err != nil { + return nil, errors.Join(fmt.Errorf("恢复证书目录失败:%w", err), rollback()) + } + } + + if err := os.RemoveAll(transaction); err != nil { + return nil, errors.Join(fmt.Errorf("恢复证书目录失败:%w", err), rollback()) + } + return func() error { return nil }, nil +} + +func moveDirectoryChildren(source, target string, excluded ...string) error { + entries, err := os.ReadDir(source) + if err != nil { + return err + } + excludedNames := make(map[string]struct{}, len(excluded)) + for _, name := range excluded { + excludedNames[name] = struct{}{} + } + for _, entry := range entries { + if _, skip := excludedNames[entry.Name()]; skip { + continue + } + if err := os.Rename(filepath.Join(source, entry.Name()), filepath.Join(target, entry.Name())); err != nil { return err } - if backupPath != "" { - return os.Rename(backupPath, target) + } + return nil +} + +func clearDirectoryChildren(directory, excluded string) error { + entries, err := os.ReadDir(directory) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil } - return nil - }, nil + return err + } + var clearErr error + for _, entry := range entries { + if entry.Name() == excluded { + continue + } + clearErr = errors.Join(clearErr, os.RemoveAll(filepath.Join(directory, entry.Name()))) + } + return clearErr } func copyDirectoryExclusive(source, target string) error { diff --git a/backend/internal/cli/backup_restore_test.go b/backend/internal/cli/backup_restore_test.go index 9905efe..8aac83e 100644 --- a/backend/internal/cli/backup_restore_test.go +++ b/backend/internal/cli/backup_restore_test.go @@ -180,7 +180,7 @@ func TestCreateBackupRequiresSecretsWhenInitialized(t *testing.T) { } } -func TestRestoreAdditionalAssetsKeepsRollbackCopies(t *testing.T) { +func TestRestoreAdditionalAssetsPreservesCertificateRoot(t *testing.T) { base := t.TempDir() cfg := config.Default(filepath.Join(base, "target")) input := filepath.Join(base, "backup") @@ -209,6 +209,9 @@ func TestRestoreAdditionalAssetsKeepsRollbackCopies(t *testing.T) { if err := restoreAdditionalAssets(cfg, input); err != nil { t.Fatalf("恢复敏感配置和证书失败:%v", err) } + if info, err := os.Stat(cfg.Paths.Certificate); err != nil || !info.IsDir() { + t.Fatalf("恢复缺失状态失败:%v", err) + } for path, expected := range map[string]string{ filepath.Join(cfg.Paths.Config, "master.key"): "new-master", filepath.Join(cfg.Paths.Config, "secrets.json"): "new-secrets", @@ -219,16 +222,21 @@ func TestRestoreAdditionalAssetsKeepsRollbackCopies(t *testing.T) { t.Fatalf("恢复结果不正确 %s:data=%q err=%v", path, data, err) } } + if _, err := os.Stat(filepath.Join(cfg.Paths.Certificate, "old.example.com")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("备份中不存在的资产仍保留在当前路径:%s err=%v", filepath.Join(cfg.Paths.Certificate, "old.example.com"), err) + } for _, pattern := range []string{ filepath.Join(cfg.Paths.Config, "master.key.before-restore-*"), filepath.Join(cfg.Paths.Config, "secrets.json.before-restore-*"), - cfg.Paths.Certificate + ".before-restore-*", } { matches, err := filepath.Glob(pattern) if err != nil || len(matches) != 1 { t.Fatalf("恢复前副本不正确 %s:matches=%v err=%v", pattern, matches, err) } } + if matches, err := filepath.Glob(filepath.Join(cfg.Paths.Certificate, ".scert-restore-*")); err != nil || len(matches) != 0 { + t.Fatalf("恢复前副本不正确 %s:matches=%v err=%v", filepath.Join(cfg.Paths.Certificate, ".scert-restore-*"), matches, err) + } } func TestRestoreAdditionalAssetsRestoresAbsentState(t *testing.T) { @@ -259,22 +267,66 @@ func TestRestoreAdditionalAssetsRestoresAbsentState(t *testing.T) { for _, path := range []string{ filepath.Join(cfg.Paths.Config, "master.key"), filepath.Join(cfg.Paths.Config, "secrets.json"), - cfg.Paths.Certificate, + filepath.Join(cfg.Paths.Certificate, "current.pem"), } { if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("备份中不存在的资产仍保留在当前路径:%s err=%v", path, err) } } + if info, err := os.Stat(cfg.Paths.Certificate); err != nil || !info.IsDir() { + t.Fatalf("恢复缺失状态失败:%v", err) + } for _, pattern := range []string{ filepath.Join(cfg.Paths.Config, "master.key.before-restore-*"), filepath.Join(cfg.Paths.Config, "secrets.json.before-restore-*"), - cfg.Paths.Certificate + ".before-restore-*", } { matches, err := filepath.Glob(pattern) if err != nil || len(matches) != 1 { t.Fatalf("缺失状态恢复未保留恢复前副本 %s:matches=%v err=%v", pattern, matches, err) } } + if matches, err := filepath.Glob(filepath.Join(cfg.Paths.Certificate, ".scert-restore-*")); err != nil || len(matches) != 0 { + t.Fatalf("恢复前副本不正确 %s:matches=%v err=%v", filepath.Join(cfg.Paths.Certificate, ".scert-restore-*"), matches, err) + } +} + +func TestRestoreDirectoryWorksWhenCertificateParentIsNotWritable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows 不提供 POSIX 目录权限语义") + } + base := t.TempDir() + parent := filepath.Join(base, "certificate-parent") + target := filepath.Join(parent, "ssl") + source := filepath.Join(base, "restore-source") + if err := os.MkdirAll(target, 0o750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(source, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "old.pem"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "new.pem"), []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(parent, 0o555); err != nil { + t.Fatal(err) + } + defer os.Chmod(parent, 0o755) + + if _, err := restoreDirectoryWithRollback(source, target, "permission"); err != nil { + t.Fatalf("恢复敏感配置和证书失败:%v", err) + } + if data, err := os.ReadFile(filepath.Join(target, "new.pem")); err != nil || string(data) != "new" { + t.Fatalf("恢复结果不正确 %s:data=%q err=%v", filepath.Join(target, "new.pem"), data, err) + } + if _, err := os.Stat(filepath.Join(target, "old.pem")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("恢复缺失状态失败:%v", err) + } + if matches, err := filepath.Glob(filepath.Join(target, ".scert-restore-*")); err != nil || len(matches) != 0 { + t.Fatalf("恢复缺失状态失败:%v", err) + } } func TestValidateRestoreCompatibilityRejectsCrossDatabase(t *testing.T) {