diff --git a/server/internal/service/execution_helpers.go b/server/internal/service/execution_helpers.go index 17004f3..384420d 100644 --- a/server/internal/service/execution_helpers.go +++ b/server/internal/service/execution_helpers.go @@ -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) diff --git a/server/internal/service/execution_helpers_test.go b/server/internal/service/execution_helpers_test.go new file mode 100644 index 0000000..59e8266 --- /dev/null +++ b/server/internal/service/execution_helpers_test.go @@ -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") + } + }) +} diff --git a/server/internal/service/restore_service.go b/server/internal/service/restore_service.go index c418cc1..64b28a2 100644 --- a/server/internal/service/restore_service.go +++ b/server/internal/service/restore_service.go @@ -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() diff --git a/server/internal/service/restore_service_test.go b/server/internal/service/restore_service_test.go index ba23551..6f0c5cc 100644 --- a/server/internal/service/restore_service_test.go +++ b/server/internal/service/restore_service_test.go @@ -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()