From 51f1909a73fda1b577b2e9cae56f108f260ec872 Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Tue, 31 Mar 2026 07:46:12 +0800 Subject: [PATCH] feat: add SHA-256 checksum verification for backup integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/internal/backup/helpers.go | 26 ++++++++++++ server/internal/model/backup_record.go | 1 + .../service/backup_execution_service.go | 40 +++++++++++++++++-- .../internal/service/backup_record_service.go | 2 + .../backup-records/BackupRecordsPage.tsx | 5 +++ web/src/types/backup-records.ts | 1 + 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/server/internal/backup/helpers.go b/server/internal/backup/helpers.go index c8d7a93..90d21a1 100644 --- a/server/internal/backup/helpers.go +++ b/server/internal/backup/helpers.go @@ -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) { diff --git a/server/internal/model/backup_record.go b/server/internal/model/backup_record.go index 5cab79a..4d4c7a8 100644 --- a/server/internal/model/backup_record.go +++ b/server/internal/model/backup_record.go @@ -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"` diff --git a/server/internal/service/backup_execution_service.go b/server/internal/service/backup_execution_service.go index 6076e22..8cd60b9 100644 --- a/server/internal/service/backup_execution_service.go +++ b/server/internal/service/backup_execution_service.go @@ -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) diff --git a/server/internal/service/backup_record_service.go b/server/internal/service/backup_record_service.go index 4a3e53c..d5ae215 100644 --- a/server/internal/service/backup_record_service.go +++ b/server/internal/service/backup_record_service.go @@ -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, diff --git a/web/src/pages/backup-records/BackupRecordsPage.tsx b/web/src/pages/backup-records/BackupRecordsPage.tsx index 6e69ddb..2543dcc 100644 --- a/web/src/pages/backup-records/BackupRecordsPage.tsx +++ b/web/src/pages/backup-records/BackupRecordsPage.tsx @@ -98,6 +98,11 @@ export function BackupRecordsPage() { {record.fileName || '-'} {formatBytes(record.fileSize)} + {record.checksum && ( + + SHA-256: {record.checksum.substring(0, 16)}... + + )} ), }, diff --git a/web/src/types/backup-records.ts b/web/src/types/backup-records.ts index ae7b8d0..2a59dc0 100644 --- a/web/src/types/backup-records.ts +++ b/web/src/types/backup-records.ts @@ -19,6 +19,7 @@ export interface BackupRecordSummary { status: BackupRecordStatus fileName: string fileSize: number + checksum: string storagePath: string durationSeconds: number errorMessage: string