From 42dba02a1bc51a0678b8f2a37c068908cc3d3e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 19:08:11 +0000 Subject: [PATCH] feat: complete ocp_binaries and rhcos Validate() with upstream checksum verification Validate() previously only checked if local files could be hashed, without comparing against expected checksums. Now it re-fetches sha256sum.txt for each configured version and verifies every file, matching the pattern used by ClientsProvider. Also adds SetValidationProgress support for live UI progress, fixes Type() return values, and cleans up misleading "stub" comments on Sync() methods. https://claude.ai/code/session_01SdEa9sX3wtA8Haq92LiCry --- internal/provider/ocp/binaries.go | 174 ++++++++++++++++------ internal/provider/ocp/ocp_test.go | 237 +++++++++++++++++++++++++++--- internal/provider/ocp/rhcos.go | 174 ++++++++++++++++------ 3 files changed, 475 insertions(+), 110 deletions(-) diff --git a/internal/provider/ocp/binaries.go b/internal/provider/ocp/binaries.go index 51437d4..9924313 100644 --- a/internal/provider/ocp/binaries.go +++ b/internal/provider/ocp/binaries.go @@ -16,10 +16,16 @@ import ( // BinariesProvider implements provider.Provider for OCP client binaries. type BinariesProvider struct { - name string - cfg *config.OCPBinariesProviderConfig - dataDir string - logger *slog.Logger + name string + cfg *config.OCPBinariesProviderConfig + dataDir string + logger *slog.Logger + validationProgressFn provider.ValidationProgressFn +} + +// SetValidationProgress sets the callback for per-file validation progress. +func (p *BinariesProvider) SetValidationProgress(fn provider.ValidationProgressFn) { + p.validationProgressFn = fn } // NewBinariesProvider creates a new OCP binaries provider. @@ -42,7 +48,7 @@ func (p *BinariesProvider) SetName(name string) { } func (p *BinariesProvider) Type() string { - return "binary" + return "ocp_binaries" } // Configure loads provider-specific settings from the raw config. @@ -133,7 +139,7 @@ func (p *BinariesProvider) Plan(ctx context.Context) (*provider.SyncPlan, error) return plan, nil } -// Sync executes the plan — downloads, validates, retries. +// Sync executes the plan — actual downloads are handled by the sync engine. func (p *BinariesProvider) Sync(ctx context.Context, plan *provider.SyncPlan, opts provider.SyncOptions) (*provider.SyncReport, error) { if p.cfg == nil { return nil, fmt.Errorf("provider not configured") @@ -153,14 +159,11 @@ func (p *BinariesProvider) Sync(ctx context.Context, plan *provider.SyncPlan, op return report, nil } - // Process each action for _, action := range plan.Actions { switch action.Action { case provider.ActionSkip: report.Skipped++ case provider.ActionDownload, provider.ActionUpdate: - // For now, stub the actual download execution. - // The sync engine would handle the actual network operations. p.logger.Debug("would download file", slog.String("path", action.Path), slog.String("url", action.URL)) @@ -183,7 +186,8 @@ func (p *BinariesProvider) Sync(ctx context.Context, plan *provider.SyncPlan, op return report, nil } -// Validate checks integrity of all local content against stored checksums. +// Validate checks integrity of all local content against upstream sha256sum.txt. +// For each configured version, it re-fetches the manifest and verifies local files. func (p *BinariesProvider) Validate(ctx context.Context) (*provider.ValidationReport, error) { if p.cfg == nil { return nil, fmt.Errorf("provider not configured") @@ -195,51 +199,133 @@ func (p *BinariesProvider) Validate(ctx context.Context) (*provider.ValidationRe Timestamp: time.Now(), } - outputPath := filepath.Join(p.dataDir, p.cfg.OutputDir) + outputRoot, err := safety.SafeJoinUnder(p.dataDir, p.cfg.OutputDir) + if err != nil { + return nil, fmt.Errorf("invalid output_dir %q: %w", p.cfg.OutputDir, err) + } - // Walk the output directory - err := filepath.Walk(outputPath, func(path string, info os.FileInfo, err error) error { + checked := 0 + + for _, version := range p.cfg.Versions { + checksumURL := fmt.Sprintf("%s/%s/sha256sum.txt", strings.TrimRight(p.cfg.BaseURL, "/"), version) + + checksumData, err := p.fetchChecksumFile(ctx, checksumURL) if err != nil { - p.logger.Warn("error walking directory", - slog.String("path", path), + p.logger.Warn("failed to fetch checksum file for validation", + slog.String("version", version), + slog.String("url", checksumURL), slog.String("error", err.Error())) - return nil - } - - // Skip directories and non-files - if info.IsDir() { - return nil + continue } - report.TotalFiles++ + remoteFiles := parseChecksumFile(checksumData) + filteredFiles := filterFiles(remoteFiles, p.cfg.IgnoredPatterns) - // Compute checksum - _, err = checksumLocalFile(path) + versionDir, err := safety.SafeJoinUnder(outputRoot, version) if err != nil { - p.logger.Error("failed to compute checksum", - slog.String("file", path), - slog.String("error", err.Error())) - report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ - Path: path, - Valid: false, - Size: info.Size(), - Actual: "error", - }) - return nil + return nil, fmt.Errorf("invalid version %q: %w", version, err) } - // For now, we don't have the expected hash stored locally. - // In a complete implementation, you'd fetch the manifest again or store it. - // Mark as valid if hash can be computed (simplification). - report.ValidFiles++ + for filename, expectedHash := range filteredFiles { + localPath, pathErr := safety.SafeJoinUnder(versionDir, filename) + if pathErr != nil { + report.TotalFiles++ + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: filepath.ToSlash(filepath.Join(version, filename)), + Expected: expectedHash, + Actual: "error: unsafe path: " + pathErr.Error(), + Valid: false, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, filepath.ToSlash(filepath.Join(version, filename)), false) + } + continue + } + + relPath, err := filepath.Rel(outputRoot, localPath) + if err != nil { + return nil, fmt.Errorf("building relative path for %q: %w", filename, err) + } + relPath = filepath.ToSlash(relPath) + + downloadURL := fmt.Sprintf("%s/%s/%s", strings.TrimRight(p.cfg.BaseURL, "/"), version, filename) + report.TotalFiles++ + + // Check if file exists + fileInfo, statErr := os.Stat(localPath) + if os.IsNotExist(statErr) { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, + LocalPath: localPath, + Expected: expectedHash, + Actual: "missing", + Valid: false, + URL: downloadURL, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, false) + } + continue + } + if statErr != nil { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, + LocalPath: localPath, + Expected: expectedHash, + Actual: "error: " + statErr.Error(), + Valid: false, + URL: downloadURL, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, false) + } + continue + } - return nil - }) + // Compute checksum and compare + actualHash, hashErr := checksumLocalFile(localPath) + if hashErr != nil { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, + LocalPath: localPath, + Expected: expectedHash, + Actual: "error: " + hashErr.Error(), + Valid: false, + Size: fileInfo.Size(), + URL: downloadURL, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, false) + } + continue + } - if err != nil && !os.IsNotExist(err) { - p.logger.Warn("error validating directory", - slog.String("path", outputPath), - slog.String("error", err.Error())) + if actualHash == expectedHash { + report.ValidFiles++ + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, true) + } + } else { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, + LocalPath: localPath, + Expected: expectedHash, + Actual: actualHash, + Valid: false, + Size: fileInfo.Size(), + URL: downloadURL, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, false) + } + } + } } p.logger.Info("validation completed", diff --git a/internal/provider/ocp/ocp_test.go b/internal/provider/ocp/ocp_test.go index 166e8d1..4f86e74 100644 --- a/internal/provider/ocp/ocp_test.go +++ b/internal/provider/ocp/ocp_test.go @@ -324,15 +324,51 @@ func TestBinariesProviderSync(t *testing.T) { } -// TestBinariesProviderValidate tests file validation +// TestBinariesProviderType verifies Type() returns expected string +func TestBinariesProviderType(t *testing.T) { + dataDir := t.TempDir() + p := NewBinariesProvider(dataDir, testLogger()) + + got := p.Type() + want := "ocp_binaries" + if got != want { + t.Errorf("Type() = %q, want %q", got, want) + } +} + +// TestBinariesProviderValidate tests file validation against upstream checksums func TestBinariesProviderValidate(t *testing.T) { dataDir := t.TempDir() p := NewBinariesProvider(dataDir, testLogger()) + // Create test content with known checksums + goodContent := []byte("good-binary-content") + goodHash := computeSHA256(goodContent) + + badContent := []byte("corrupted-content") + badExpectedHash := computeSHA256([]byte("original-content")) + + missingHash := computeSHA256([]byte("missing-file")) + + // Create mock HTTP server serving sha256sum.txt + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/4.18/sha256sum.txt" { + checksumContent := fmt.Sprintf("%s good-binary.tar.gz\n%s bad-binary.tar.gz\n%s missing-binary.tar.gz\n", + goodHash, badExpectedHash, missingHash) + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(checksumContent)); err != nil { + t.Fatalf("failed to write test response: %v", err) + } + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + // Configure provider rawCfg := provider.ProviderConfig{ "enabled": true, - "base_url": "https://example.com", + "base_url": server.URL, "versions": []interface{}{"4.18"}, "output_dir": "ocp-binaries", "retry_attempts": 3, @@ -351,14 +387,20 @@ func TestBinariesProviderValidate(t *testing.T) { t.Fatalf("failed to create output directory: %v", err) } - // Create test file - testFile := filepath.Join(outputPath, "test-binary.tar.gz") - testContent := []byte("binary-content") - err = os.WriteFile(testFile, testContent, 0644) + // Write good file (matching checksum) + err = os.WriteFile(filepath.Join(outputPath, "good-binary.tar.gz"), goodContent, 0644) if err != nil { - t.Fatalf("failed to write test file: %v", err) + t.Fatalf("failed to write good file: %v", err) } + // Write bad file (mismatching checksum) + err = os.WriteFile(filepath.Join(outputPath, "bad-binary.tar.gz"), badContent, 0644) + if err != nil { + t.Fatalf("failed to write bad file: %v", err) + } + + // missing-binary.tar.gz is intentionally not created + // Call Validate ctx := context.Background() report, err := p.Validate(ctx) @@ -374,16 +416,103 @@ func TestBinariesProviderValidate(t *testing.T) { t.Errorf("Report.Provider = %q, want %q", report.Provider, "ocp_binaries") } - if report.TotalFiles != 1 { - t.Errorf("Report.TotalFiles = %d, want 1", report.TotalFiles) + if report.TotalFiles != 3 { + t.Errorf("Report.TotalFiles = %d, want 3", report.TotalFiles) } if report.ValidFiles != 1 { t.Errorf("Report.ValidFiles = %d, want 1", report.ValidFiles) } - if len(report.InvalidFiles) != 0 { - t.Errorf("Report.InvalidFiles length = %d, want 0", len(report.InvalidFiles)) + if len(report.InvalidFiles) != 2 { + t.Errorf("Report.InvalidFiles length = %d, want 2", len(report.InvalidFiles)) + } + + // Verify invalid file details + invalidMap := make(map[string]provider.ValidationResult) + for _, vr := range report.InvalidFiles { + invalidMap[filepath.Base(vr.Path)] = vr + } + + if missing, ok := invalidMap["missing-binary.tar.gz"]; ok { + if missing.Actual != "missing" { + t.Errorf("missing file Actual = %q, want %q", missing.Actual, "missing") + } + } else { + t.Error("expected missing-binary.tar.gz in invalid files") + } + + if bad, ok := invalidMap["bad-binary.tar.gz"]; ok { + if bad.Actual == "missing" { + t.Error("bad-binary.tar.gz should not be 'missing', it exists with wrong checksum") + } + if bad.Expected != badExpectedHash { + t.Errorf("bad file Expected = %q, want %q", bad.Expected, badExpectedHash) + } + } else { + t.Error("expected bad-binary.tar.gz in invalid files") + } +} + +// TestBinariesProviderValidateProgress tests that validation progress callback fires +func TestBinariesProviderValidateProgress(t *testing.T) { + dataDir := t.TempDir() + p := NewBinariesProvider(dataDir, testLogger()) + + goodContent := []byte("good-content") + goodHash := computeSHA256(goodContent) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/4.18/sha256sum.txt" { + checksumContent := fmt.Sprintf("%s file.tar.gz\n", goodHash) + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(checksumContent)); err != nil { + t.Fatalf("failed to write: %v", err) + } + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + rawCfg := provider.ProviderConfig{ + "enabled": true, + "base_url": server.URL, + "versions": []interface{}{"4.18"}, + "output_dir": "ocp-binaries", + "ignored_patterns": []interface{}{}, + } + if err := p.Configure(rawCfg); err != nil { + t.Fatalf("Configure() failed: %v", err) + } + + outputPath := filepath.Join(dataDir, "ocp-binaries", "4.18") + if err := os.MkdirAll(outputPath, 0755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(outputPath, "file.tar.gz"), goodContent, 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + progressCalls := 0 + p.SetValidationProgress(func(checked, total int, path string, valid bool) { + progressCalls++ + if !valid { + t.Errorf("expected valid=true for matching file, got false") + } + }) + + ctx := context.Background() + report, err := p.Validate(ctx) + if err != nil { + t.Fatalf("Validate() failed: %v", err) + } + + if progressCalls != 1 { + t.Errorf("progress callback called %d times, want 1", progressCalls) + } + if report.ValidFiles != 1 { + t.Errorf("ValidFiles = %d, want 1", report.ValidFiles) } } @@ -670,15 +799,51 @@ func TestRHCOSProviderSync(t *testing.T) { } } -// TestRHCOSProviderValidate tests file validation +// TestRHCOSProviderType verifies Type() returns expected string +func TestRHCOSProviderType(t *testing.T) { + dataDir := t.TempDir() + p := NewRHCOSProvider(dataDir, testLogger()) + + got := p.Type() + want := "rhcos" + if got != want { + t.Errorf("Type() = %q, want %q", got, want) + } +} + +// TestRHCOSProviderValidate tests file validation against upstream checksums func TestRHCOSProviderValidate(t *testing.T) { dataDir := t.TempDir() p := NewRHCOSProvider(dataDir, testLogger()) + // Create test content with known checksums + goodContent := []byte("rhcos-vmware-disk-data") + goodHash := computeSHA256(goodContent) + + badContent := []byte("corrupted-image") + badExpectedHash := computeSHA256([]byte("original-image")) + + missingHash := computeSHA256([]byte("missing-image")) + + // Create mock HTTP server serving sha256sum.txt + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/418.1/sha256sum.txt" { + checksumContent := fmt.Sprintf("%s rhcos-418.1-vmware.ova\n%s rhcos-418.1-bad.ova\n%s rhcos-418.1-missing.ova\n", + goodHash, badExpectedHash, missingHash) + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(checksumContent)); err != nil { + t.Fatalf("failed to write test response: %v", err) + } + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + // Configure provider rawCfg := provider.ProviderConfig{ "enabled": true, - "base_url": "https://example.com", + "base_url": server.URL, "versions": []interface{}{"418.1"}, "output_dir": "rhcos-images", "retry_attempts": 3, @@ -697,14 +862,20 @@ func TestRHCOSProviderValidate(t *testing.T) { t.Fatalf("failed to create output directory: %v", err) } - // Create test file - testFile := filepath.Join(outputPath, "rhcos-418.1-qemu.qcow2.gz") - testContent := []byte("rhcos-image-content") - err = os.WriteFile(testFile, testContent, 0644) + // Write good file (matching checksum) + err = os.WriteFile(filepath.Join(outputPath, "rhcos-418.1-vmware.ova"), goodContent, 0644) if err != nil { - t.Fatalf("failed to write test file: %v", err) + t.Fatalf("failed to write good file: %v", err) } + // Write bad file (mismatching checksum) + err = os.WriteFile(filepath.Join(outputPath, "rhcos-418.1-bad.ova"), badContent, 0644) + if err != nil { + t.Fatalf("failed to write bad file: %v", err) + } + + // missing file intentionally not created + // Call Validate ctx := context.Background() report, err := p.Validate(ctx) @@ -720,16 +891,38 @@ func TestRHCOSProviderValidate(t *testing.T) { t.Errorf("Report.Provider = %q, want %q", report.Provider, "rhcos") } - if report.TotalFiles != 1 { - t.Errorf("Report.TotalFiles = %d, want 1", report.TotalFiles) + if report.TotalFiles != 3 { + t.Errorf("Report.TotalFiles = %d, want 3", report.TotalFiles) } if report.ValidFiles != 1 { t.Errorf("Report.ValidFiles = %d, want 1", report.ValidFiles) } - if len(report.InvalidFiles) != 0 { - t.Errorf("Report.InvalidFiles length = %d, want 0", len(report.InvalidFiles)) + if len(report.InvalidFiles) != 2 { + t.Errorf("Report.InvalidFiles length = %d, want 2", len(report.InvalidFiles)) + } + + // Verify invalid file details + invalidMap := make(map[string]provider.ValidationResult) + for _, vr := range report.InvalidFiles { + invalidMap[filepath.Base(vr.Path)] = vr + } + + if missing, ok := invalidMap["rhcos-418.1-missing.ova"]; ok { + if missing.Actual != "missing" { + t.Errorf("missing file Actual = %q, want %q", missing.Actual, "missing") + } + } else { + t.Error("expected rhcos-418.1-missing.ova in invalid files") + } + + if bad, ok := invalidMap["rhcos-418.1-bad.ova"]; ok { + if bad.Expected != badExpectedHash { + t.Errorf("bad file Expected = %q, want %q", bad.Expected, badExpectedHash) + } + } else { + t.Error("expected rhcos-418.1-bad.ova in invalid files") } } diff --git a/internal/provider/ocp/rhcos.go b/internal/provider/ocp/rhcos.go index e0551c3..85a5036 100644 --- a/internal/provider/ocp/rhcos.go +++ b/internal/provider/ocp/rhcos.go @@ -16,10 +16,16 @@ import ( // RHCOSProvider implements provider.Provider for RHCOS images. type RHCOSProvider struct { - name string - cfg *config.RHCOSProviderConfig - dataDir string - logger *slog.Logger + name string + cfg *config.RHCOSProviderConfig + dataDir string + logger *slog.Logger + validationProgressFn provider.ValidationProgressFn +} + +// SetValidationProgress sets the callback for per-file validation progress. +func (p *RHCOSProvider) SetValidationProgress(fn provider.ValidationProgressFn) { + p.validationProgressFn = fn } // NewRHCOSProvider creates a new RHCOS provider. @@ -42,7 +48,7 @@ func (p *RHCOSProvider) SetName(name string) { } func (p *RHCOSProvider) Type() string { - return "binary" + return "rhcos" } // Configure loads provider-specific settings from the raw config. @@ -133,7 +139,7 @@ func (p *RHCOSProvider) Plan(ctx context.Context) (*provider.SyncPlan, error) { return plan, nil } -// Sync executes the plan — downloads, validates, retries. +// Sync executes the plan — actual downloads are handled by the sync engine. func (p *RHCOSProvider) Sync(ctx context.Context, plan *provider.SyncPlan, opts provider.SyncOptions) (*provider.SyncReport, error) { if p.cfg == nil { return nil, fmt.Errorf("provider not configured") @@ -153,14 +159,11 @@ func (p *RHCOSProvider) Sync(ctx context.Context, plan *provider.SyncPlan, opts return report, nil } - // Process each action for _, action := range plan.Actions { switch action.Action { case provider.ActionSkip: report.Skipped++ case provider.ActionDownload, provider.ActionUpdate: - // For now, stub the actual download execution. - // The sync engine would handle the actual network operations. p.logger.Debug("would download file", slog.String("path", action.Path), slog.String("url", action.URL)) @@ -183,7 +186,8 @@ func (p *RHCOSProvider) Sync(ctx context.Context, plan *provider.SyncPlan, opts return report, nil } -// Validate checks integrity of all local content against stored checksums. +// Validate checks integrity of all local content against upstream sha256sum.txt. +// For each configured version, it re-fetches the manifest and verifies local files. func (p *RHCOSProvider) Validate(ctx context.Context) (*provider.ValidationReport, error) { if p.cfg == nil { return nil, fmt.Errorf("provider not configured") @@ -195,51 +199,133 @@ func (p *RHCOSProvider) Validate(ctx context.Context) (*provider.ValidationRepor Timestamp: time.Now(), } - outputPath := filepath.Join(p.dataDir, p.cfg.OutputDir) + outputRoot, err := safety.SafeJoinUnder(p.dataDir, p.cfg.OutputDir) + if err != nil { + return nil, fmt.Errorf("invalid output_dir %q: %w", p.cfg.OutputDir, err) + } - // Walk the output directory - err := filepath.Walk(outputPath, func(path string, info os.FileInfo, err error) error { + checked := 0 + + for _, version := range p.cfg.Versions { + checksumURL := fmt.Sprintf("%s/%s/sha256sum.txt", strings.TrimRight(p.cfg.BaseURL, "/"), version) + + checksumData, err := p.fetchChecksumFile(ctx, checksumURL) if err != nil { - p.logger.Warn("error walking directory", - slog.String("path", path), + p.logger.Warn("failed to fetch checksum file for validation", + slog.String("version", version), + slog.String("url", checksumURL), slog.String("error", err.Error())) - return nil - } - - // Skip directories and non-files - if info.IsDir() { - return nil + continue } - report.TotalFiles++ + remoteFiles := parseChecksumFile(checksumData) + filteredFiles := filterFiles(remoteFiles, p.cfg.IgnoredPatterns) - // Compute checksum - _, err = checksumLocalFile(path) + versionDir, err := safety.SafeJoinUnder(outputRoot, version) if err != nil { - p.logger.Error("failed to compute checksum", - slog.String("file", path), - slog.String("error", err.Error())) - report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ - Path: path, - Valid: false, - Size: info.Size(), - Actual: "error", - }) - return nil + return nil, fmt.Errorf("invalid version %q: %w", version, err) } - // For now, we don't have the expected hash stored locally. - // In a complete implementation, you'd fetch the manifest again or store it. - // Mark as valid if hash can be computed (simplification). - report.ValidFiles++ + for filename, expectedHash := range filteredFiles { + localPath, pathErr := safety.SafeJoinUnder(versionDir, filename) + if pathErr != nil { + report.TotalFiles++ + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: filepath.ToSlash(filepath.Join(version, filename)), + Expected: expectedHash, + Actual: "error: unsafe path: " + pathErr.Error(), + Valid: false, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, filepath.ToSlash(filepath.Join(version, filename)), false) + } + continue + } + + relPath, err := filepath.Rel(outputRoot, localPath) + if err != nil { + return nil, fmt.Errorf("building relative path for %q: %w", filename, err) + } + relPath = filepath.ToSlash(relPath) + + downloadURL := fmt.Sprintf("%s/%s/%s", strings.TrimRight(p.cfg.BaseURL, "/"), version, filename) + report.TotalFiles++ + + // Check if file exists + fileInfo, statErr := os.Stat(localPath) + if os.IsNotExist(statErr) { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, + LocalPath: localPath, + Expected: expectedHash, + Actual: "missing", + Valid: false, + URL: downloadURL, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, false) + } + continue + } + if statErr != nil { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, + LocalPath: localPath, + Expected: expectedHash, + Actual: "error: " + statErr.Error(), + Valid: false, + URL: downloadURL, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, false) + } + continue + } - return nil - }) + // Compute checksum and compare + actualHash, hashErr := checksumLocalFile(localPath) + if hashErr != nil { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, + LocalPath: localPath, + Expected: expectedHash, + Actual: "error: " + hashErr.Error(), + Valid: false, + Size: fileInfo.Size(), + URL: downloadURL, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, false) + } + continue + } - if err != nil && !os.IsNotExist(err) { - p.logger.Warn("error validating directory", - slog.String("path", outputPath), - slog.String("error", err.Error())) + if actualHash == expectedHash { + report.ValidFiles++ + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, true) + } + } else { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, + LocalPath: localPath, + Expected: expectedHash, + Actual: actualHash, + Valid: false, + Size: fileInfo.Size(), + URL: downloadURL, + }) + checked++ + if p.validationProgressFn != nil { + p.validationProgressFn(checked, report.TotalFiles, relPath, false) + } + } + } } p.logger.Info("validation completed",