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",