mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-28 07:29:38 +08:00
feat(restore): 还原前校验备份 SHA-256,拒绝损坏/被篡改的备份 (#75)
恢复路径在解密/解压前比对下载对象的 SHA-256(与备份记录一致),不匹配即中止还原且不触碰源数据;早期无 checksum 的备份跳过(向后兼容)。新增单元+集成测试。
This commit is contained in:
@@ -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)
|
||||
|
||||
59
server/internal/service/execution_helpers_test.go
Normal file
59
server/internal/service/execution_helpers_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user