feat: add SHA-256 checksum verification for backup integrity

Addresses community feedback about 0KB corrupted backup files going
undetected after upload.

Implementation:
- Compute SHA-256 hash of final artifact (after compress/encrypt) before upload
- After each storage target upload, download the file back and verify
  the hash matches the local checksum
- If verification fails: mark that target as failed, auto-delete the
  corrupted remote file, and log detailed mismatch info
- Store checksum in BackupRecord model (new `checksum` column)
- Display truncated SHA-256 with copy button in backup records UI

Verification flow per storage target:
  local SHA-256 → upload → download → remote SHA-256 → compare
  - match: mark success
  - mismatch: mark failed + delete corrupted remote file
This commit is contained in:
Awuqing
2026-03-31 07:46:12 +08:00
parent 2ace5a5352
commit 2537149b39
6 changed files with 72 additions and 3 deletions

View File

@@ -1,7 +1,10 @@
package backup
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -21,6 +24,29 @@ func createTempArtifact(baseDir, taskName string, extension string) (string, str
return tempDir, filepath.Join(tempDir, fileName), nil
}
// SHA256File 计算文件的 SHA-256 哈希值,返回十六进制字符串
func SHA256File(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("open file for checksum: %w", err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", fmt.Errorf("compute checksum: %w", err)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// SHA256Reader 计算 reader 的 SHA-256 哈希值,返回十六进制字符串
func SHA256Reader(reader io.Reader) (string, error) {
hash := sha256.New()
if _, err := io.Copy(hash, reader); err != nil {
return "", fmt.Errorf("compute checksum: %w", err)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func sanitizeFileName(value string) string {
builder := strings.Builder{}
for _, char := range strings.TrimSpace(value) {

View File

@@ -17,6 +17,7 @@ type BackupRecord struct {
Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`

View File

@@ -253,10 +253,11 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
errMessage := ""
var fileName string
var fileSize int64
var checksum string
var storagePath string
var uploadResults []StorageUploadResultItem
completeRecord := func() {
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, storagePath); finalizeErr != nil {
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath); finalizeErr != nil {
logger.Errorf("写回备份记录失败:%v", finalizeErr)
}
// 写入多目标上传结果
@@ -325,6 +326,17 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
fileName = filepath.Base(finalPath)
storagePath = backup.BuildStorageKey(task.Type, startedAt, fileName)
// 计算文件 SHA-256 哈希(上传前)
logger.Infof("计算备份文件校验和...")
localChecksum, checksumErr := backup.SHA256File(finalPath)
if checksumErr != nil {
errMessage = checksumErr.Error()
logger.Errorf("计算文件校验和失败:%v", checksumErr)
return
}
checksum = localChecksum
logger.Infof("文件校验和: SHA-256=%s, 大小=%d bytes", checksum, fileSize)
// 收集所有存储目标
targetIDs := collectTargetIDs(task)
if len(targetIDs) == 0 {
@@ -364,8 +376,29 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr)
return
}
// 上传后完整性校验:下载并验证 SHA-256
verifyReader, verifyErr := provider.Download(ctx, storagePath)
if verifyErr != nil {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: fmt.Sprintf("上传后校验失败(无法下载验证): %v", verifyErr)}
logger.Warnf("存储目标 %s 上传后下载验证失败:%v", targetName, verifyErr)
return
}
remoteChecksum, hashErr := backup.SHA256Reader(verifyReader)
verifyReader.Close()
if hashErr != nil {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: fmt.Sprintf("上传后校验失败(哈希计算错误): %v", hashErr)}
logger.Warnf("存储目标 %s 上传后哈希计算失败:%v", targetName, hashErr)
return
}
if remoteChecksum != localChecksum {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: fmt.Sprintf("完整性校验失败: 本地=%s, 远端=%s", localChecksum, remoteChecksum)}
logger.Errorf("存储目标 %s 完整性校验失败:本地 SHA-256=%s, 远端 SHA-256=%s", targetName, localChecksum, remoteChecksum)
// 删除损坏的远端文件
_ = provider.Delete(ctx, storagePath)
return
}
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "success", StoragePath: storagePath, FileSize: fileSize}
logger.Infof("存储目标 %s 上传成功", targetName)
logger.Infof("存储目标 %s 上传成功,完整性校验通过 (SHA-256=%s)", targetName, localChecksum)
// 每个成功目标独立执行保留策略
if s.retention != nil {
cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider)
@@ -403,7 +436,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
}
func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, storagePath string) error {
func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, checksum string, storagePath string) error {
record, err := s.records.FindByID(ctx, recordID)
if err != nil {
return err
@@ -415,6 +448,7 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
record.Status = status
record.FileName = fileName
record.FileSize = fileSize
record.Checksum = checksum
record.StoragePath = storagePath
record.DurationSeconds = int(completedAt.Sub(startedAt).Seconds())
record.ErrorMessage = strings.TrimSpace(errorMessage)

View File

@@ -30,6 +30,7 @@ type BackupRecordSummary struct {
Status string `json:"status"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
Checksum string `json:"checksum"`
StoragePath string `json:"storagePath"`
DurationSeconds int `json:"durationSeconds"`
ErrorMessage string `json:"errorMessage"`
@@ -111,6 +112,7 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
Status: item.Status,
FileName: item.FileName,
FileSize: item.FileSize,
Checksum: item.Checksum,
StoragePath: item.StoragePath,
DurationSeconds: item.DurationSeconds,
ErrorMessage: item.ErrorMessage,