mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-04 23:16:41 +08:00
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:
@@ -1,7 +1,10 @@
|
|||||||
package backup
|
package backup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,6 +24,29 @@ func createTempArtifact(baseDir, taskName string, extension string) (string, str
|
|||||||
return tempDir, filepath.Join(tempDir, fileName), nil
|
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 {
|
func sanitizeFileName(value string) string {
|
||||||
builder := strings.Builder{}
|
builder := strings.Builder{}
|
||||||
for _, char := range strings.TrimSpace(value) {
|
for _, char := range strings.TrimSpace(value) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type BackupRecord struct {
|
|||||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||||
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
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"`
|
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
||||||
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
||||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||||
|
|||||||
@@ -253,10 +253,11 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
errMessage := ""
|
errMessage := ""
|
||||||
var fileName string
|
var fileName string
|
||||||
var fileSize int64
|
var fileSize int64
|
||||||
|
var checksum string
|
||||||
var storagePath string
|
var storagePath string
|
||||||
var uploadResults []StorageUploadResultItem
|
var uploadResults []StorageUploadResultItem
|
||||||
completeRecord := func() {
|
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)
|
logger.Errorf("写回备份记录失败:%v", finalizeErr)
|
||||||
}
|
}
|
||||||
// 写入多目标上传结果
|
// 写入多目标上传结果
|
||||||
@@ -325,6 +326,17 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
fileName = filepath.Base(finalPath)
|
fileName = filepath.Base(finalPath)
|
||||||
storagePath = backup.BuildStorageKey(task.Type, startedAt, fileName)
|
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)
|
targetIDs := collectTargetIDs(task)
|
||||||
if len(targetIDs) == 0 {
|
if len(targetIDs) == 0 {
|
||||||
@@ -364,8 +376,29 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr)
|
logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr)
|
||||||
return
|
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}
|
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 {
|
if s.retention != nil {
|
||||||
cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider)
|
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)
|
record, err := s.records.FindByID(ctx, recordID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -415,6 +448,7 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
|
|||||||
record.Status = status
|
record.Status = status
|
||||||
record.FileName = fileName
|
record.FileName = fileName
|
||||||
record.FileSize = fileSize
|
record.FileSize = fileSize
|
||||||
|
record.Checksum = checksum
|
||||||
record.StoragePath = storagePath
|
record.StoragePath = storagePath
|
||||||
record.DurationSeconds = int(completedAt.Sub(startedAt).Seconds())
|
record.DurationSeconds = int(completedAt.Sub(startedAt).Seconds())
|
||||||
record.ErrorMessage = strings.TrimSpace(errorMessage)
|
record.ErrorMessage = strings.TrimSpace(errorMessage)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type BackupRecordSummary struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
FileName string `json:"fileName"`
|
FileName string `json:"fileName"`
|
||||||
FileSize int64 `json:"fileSize"`
|
FileSize int64 `json:"fileSize"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
StoragePath string `json:"storagePath"`
|
StoragePath string `json:"storagePath"`
|
||||||
DurationSeconds int `json:"durationSeconds"`
|
DurationSeconds int `json:"durationSeconds"`
|
||||||
ErrorMessage string `json:"errorMessage"`
|
ErrorMessage string `json:"errorMessage"`
|
||||||
@@ -111,6 +112,7 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
|
|||||||
Status: item.Status,
|
Status: item.Status,
|
||||||
FileName: item.FileName,
|
FileName: item.FileName,
|
||||||
FileSize: item.FileSize,
|
FileSize: item.FileSize,
|
||||||
|
Checksum: item.Checksum,
|
||||||
StoragePath: item.StoragePath,
|
StoragePath: item.StoragePath,
|
||||||
DurationSeconds: item.DurationSeconds,
|
DurationSeconds: item.DurationSeconds,
|
||||||
ErrorMessage: item.ErrorMessage,
|
ErrorMessage: item.ErrorMessage,
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ export function BackupRecordsPage() {
|
|||||||
<Space direction="vertical" size={2}>
|
<Space direction="vertical" size={2}>
|
||||||
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
||||||
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
||||||
|
{record.checksum && (
|
||||||
|
<Typography.Text type="secondary" copyable style={{ fontSize: 11 }}>
|
||||||
|
SHA-256: {record.checksum.substring(0, 16)}...
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface BackupRecordSummary {
|
|||||||
status: BackupRecordStatus
|
status: BackupRecordStatus
|
||||||
fileName: string
|
fileName: string
|
||||||
fileSize: number
|
fileSize: number
|
||||||
|
checksum: string
|
||||||
storagePath: string
|
storagePath: string
|
||||||
durationSeconds: number
|
durationSeconds: number
|
||||||
errorMessage: string
|
errorMessage: string
|
||||||
|
|||||||
Reference in New Issue
Block a user