完善证书恢复目录回滚

This commit is contained in:
xuans 2026-09-14 21:07:25 +08:00
parent 4402beeee1
commit ca7c2786d8
2 changed files with 146 additions and 29 deletions

View File

@ -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 {

View File

@ -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) {