feat(restore): 还原前校验备份 SHA-256,拒绝损坏/被篡改的备份 (#75)

恢复路径在解密/解压前比对下载对象的 SHA-256(与备份记录一致),不匹配即中止还原且不触碰源数据;早期无 checksum 的备份跳过(向后兼容)。新增单元+集成测试。
This commit is contained in:
Wu Qing
2026-05-27 00:27:41 +08:00
committed by GitHub
parent 0f30e7bf52
commit 45bc210313
4 changed files with 183 additions and 0 deletions

View File

@@ -2,8 +2,12 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
@@ -25,6 +29,43 @@ import (
// 另一处"的不一致缺陷。这里抽取为单一实现,各服务通过薄封装方法委托调用,调用方
// 无需改动。
// fileSHA256 计算文件内容的 SHA-256小写 hex与备份上传时记录到
// BackupRecord.Checksum 的格式一致,用于恢复/复制前的完整性校验。
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// verifyArtifactChecksum 校验下载到本地的备份对象与记录的 SHA-256 是否一致。
// expected 为空时跳过(兼容早期未记录 checksum 的备份);不一致返回结构化错误,
// 调用方应据此中止恢复,避免还原已损坏或被篡改的数据。
func verifyArtifactChecksum(path, expected string) error {
expected = strings.TrimSpace(expected)
if expected == "" {
return nil
}
actual, err := fileSHA256(path)
if err != nil {
return apperror.Internal("BACKUP_CHECKSUM_READ_FAILED", "无法读取备份文件计算校验和", err)
}
if !strings.EqualFold(actual, expected) {
// 包装错误同样使用中文并附上期望/实际哈希apperror.Error() 会优先返回包装错误,
// 而恢复记录的 ErrorMessage 取自 err.Error(),需保证对用户可读。
return apperror.BadRequest("BACKUP_CHECKSUM_MISMATCH",
"备份文件完整性校验失败SHA-256 不匹配,文件可能已损坏或被篡改",
fmt.Errorf("备份文件完整性校验失败SHA-256 不匹配(期望 %s实际 %s文件可能已损坏或被篡改", expected, actual))
}
return nil
}
// resolveStorageProvider 查询存储目标、解密其配置并创建 provider。
func resolveStorageProvider(ctx context.Context, targets repository.StorageTargetRepository, registry *storage.Registry, cipher *codec.ConfigCipher, targetID uint) (storage.StorageProvider, error) {
target, err := targets.FindByID(ctx, targetID)

View File

@@ -0,0 +1,59 @@
package service
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFileSHA256(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.bin")
if err := os.WriteFile(p, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
// echo -n hello | sha256sum
const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
got, err := fileSHA256(p)
if err != nil {
t.Fatalf("fileSHA256: %v", err)
}
if got != want {
t.Fatalf("fileSHA256 = %q, want %q", got, want)
}
}
func TestVerifyArtifactChecksum(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "artifact.tar.gz")
if err := os.WriteFile(p, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
const sum = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
t.Run("empty expected skips (backward compat)", func(t *testing.T) {
if err := verifyArtifactChecksum(p, ""); err != nil {
t.Fatalf("empty expected should skip, got %v", err)
}
})
t.Run("matching checksum passes (case-insensitive)", func(t *testing.T) {
if err := verifyArtifactChecksum(p, strings.ToUpper(sum)); err != nil {
t.Fatalf("matching checksum should pass, got %v", err)
}
})
t.Run("mismatch is rejected", func(t *testing.T) {
err := verifyArtifactChecksum(p, "deadbeef")
if err == nil {
t.Fatal("mismatch should error")
}
if !strings.Contains(err.Error(), "完整性校验失败") {
t.Fatalf("unexpected error message: %v", err)
}
})
t.Run("missing file errors", func(t *testing.T) {
if err := verifyArtifactChecksum(filepath.Join(dir, "nope"), sum); err == nil {
t.Fatal("missing file should error")
}
})
}

View File

@@ -260,6 +260,17 @@ func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, tas
logger.Errorf("写入恢复文件失败:%v", writeErr)
return
}
// 完整性校验:在解密/解压前比对下载对象的 SHA-256拒绝还原损坏或被篡改的备份。
// 早期未记录 checksum 的备份会跳过(向后兼容)。
if backupRecord.Checksum != "" {
logger.Infof("校验备份完整性SHA-256")
if csErr := verifyArtifactChecksum(artifactPath, backupRecord.Checksum); csErr != nil {
errMessage = csErr.Error()
logger.Errorf("完整性校验失败:%v", csErr)
return
}
logger.Infof("完整性校验通过")
}
preparedPath, prepareErr := s.prepareArtifact(artifactPath, logger)
if prepareErr != nil {
errMessage = prepareErr.Error()

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -197,6 +198,77 @@ func TestRestoreServiceStart_LocalNodeExecutesInline(t *testing.T) {
}
}
// TestRestoreServiceStart_RejectsCorruptedBackup 验证恢复在还原前做 SHA-256 完整性
// 校验:若已存储的备份对象被损坏/篡改,恢复必须失败且不触碰源数据。
func TestRestoreServiceStart_RejectsCorruptedBackup(t *testing.T) {
h := newRestoreTestHarness(t, false)
ctx := context.Background()
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if backupDetail.Status != "success" {
t.Fatalf("expected backup success, got %s", backupDetail.Status)
}
// 破坏已存储的备份对象:追加垃圾字节,使其 SHA-256 与记录不符。
corrupted := false
if walkErr := filepath.Walk(h.storageDir, func(p string, info os.FileInfo, walkErr error) error {
if walkErr != nil || info.IsDir() {
return walkErr
}
f, openErr := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0o644)
if openErr != nil {
return openErr
}
defer f.Close()
if _, writeErr := f.WriteString("corrupt"); writeErr != nil {
return writeErr
}
corrupted = true
return nil
}); walkErr != nil {
t.Fatalf("corrupt walk: %v", walkErr)
}
if !corrupted {
t.Fatal("did not find a stored backup object to corrupt")
}
if err := os.RemoveAll(h.sourceDir); err != nil {
t.Fatalf("remove source: %v", err)
}
done := make(chan struct{})
h.service.async = func(job func()) {
go func() { job(); close(done) }()
}
detail, err := h.service.Start(ctx, backupDetail.ID, "tester")
if err != nil {
t.Fatalf("Start: %v", err)
}
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatalf("restore did not complete in time")
}
final, err := h.service.Get(ctx, detail.ID)
if err != nil {
t.Fatalf("Get final: %v", err)
}
if final.Status != model.RestoreRecordStatusFailed {
t.Fatalf("expected restore to FAIL on corrupted backup, got %s (err=%s)", final.Status, final.ErrorMessage)
}
if !strings.Contains(final.ErrorMessage, "完整性校验失败") && !strings.Contains(final.ErrorMessage, "SHA-256") {
t.Fatalf("expected checksum failure message, got %q", final.ErrorMessage)
}
// 校验阶段即中止,不应触碰源数据。
if _, statErr := os.Stat(filepath.Join(h.sourceDir, "index.html")); statErr == nil {
t.Fatal("source must not be restored when checksum verification fails")
}
}
func TestRestoreServiceStart_RemoteNodeEnqueuesCommand(t *testing.T) {
h := newRestoreTestHarness(t, true)
ctx := context.Background()