diff --git a/.gitignore b/.gitignore index 1ef00f3..4a4996d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ local-build-commands.txt *.log .DS_Store + +!**/rulesets/**/*.tar.gz diff --git a/CHANGELOG.md b/CHANGELOG.md index 8024680..6721990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ### Added - Upcoming changes... +## [0.9.0] - 2025-12-29 +### Added +- Added gRPC DownloadRuleset and REST endpoint GET /v2/cryptography/rulesets/download + - Supports downloading cryptography detection rulesets by name and version + - Supports "latest" keyword for retrieving the most recent version + - Returns tarball with appropriate HTTP headers (Content-Disposition, SCANOSS-Ruleset-Name, SCANOSS-Ruleset-Version, X-Checksum-SHA256) + - Includes metadata validation and version resolution via symlinks + +### Fixed +- Fixed OpenTelemetry metrics initialization by properly exporting SetupMetrics function + ## [0.8.1] - 2025-10-16 ### Fixed - Fixed OpenTelemetry metrics initialization by exporting SetupMetrics function and calling it on server startup @@ -96,6 +107,7 @@ - Remove from list those versions that do not contain detections - Detailed response status message. +[0.9.0]: https://github.com/scanoss/cryptography/compare/v0.8.1...v0.9.0 [0.8.1]: https://github.com/scanoss/cryptography/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/scanoss/cryptography/compare/v0.7.1...v0.8.0 [0.7.1]: https://github.com/scanoss/cryptography/compare/v0.7.0...v0.7.1 @@ -104,4 +116,4 @@ [0.5.0]: https://github.com/scanoss/cryptography/compare/v0.4.2...v0.5.0 [0.4.2]: https://github.com/scanoss/cryptography/releases/tag/v0.4.1....v0.4.2 [0.4.1]: https://github.com/scanoss/cryptography/releases/tag/v0.4.0...v0.4.1 -[0.4.0]: https://github.com/scanoss/cryptography/releases/tag/v0.4.0 \ No newline at end of file +[0.4.0]: https://github.com/scanoss/cryptography/releases/tag/v0.4.0 diff --git a/config/app-config-dev.json b/config/app-config-dev.json index 6acd545..c5dc856 100644 --- a/config/app-config-dev.json +++ b/config/app-config-dev.json @@ -2,11 +2,14 @@ "App": { "Name": "Cryptography Service", "Debug": true, - "Mode": "dev" + "Mode": "dev" }, "Database": { "Dsn": "./test-support/sqlite/scanoss.db?cache=shared&mode=memory", "Driver": "sqlite", "Trace": true + }, + "Rulesets": { + "StoragePath": "test-support/rulesets" } } diff --git a/go.mod b/go.mod index 884183b..c761eb7 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/lib/pq v1.10.9 github.com/scanoss/go-grpc-helper v0.9.0 github.com/scanoss/go-purl-helper v0.2.1 - github.com/scanoss/papi v0.25.1 + github.com/scanoss/papi v0.27.0 github.com/scanoss/zap-logging-helper v0.4.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.38.0 @@ -37,7 +37,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.6.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect ) @@ -70,6 +70,6 @@ require ( // Details of how to use the "replace" command for local development // https://github.com/golang/go/wiki/Modules#when-should-i-use-the-replace-directive // ie. replace github.com/scanoss/papi => ../papi -// replace github.com/scanoss/papi => ../papi +//replace github.com/scanoss/papi => ../papi // require github.com/scanoss/papi v0.0.0-unpublished diff --git a/go.sum b/go.sum index 650a00c..74ade2d 100644 --- a/go.sum +++ b/go.sum @@ -626,8 +626,8 @@ github.com/scanoss/go-purl-helper v0.2.1 h1:jp960a585ycyJSlqZky1NatMJBIQi/JGITDf github.com/scanoss/go-purl-helper v0.2.1/go.mod h1:v20/bKD8G+vGrILdiq6r0hyRD2bO8frCJlu9drEcQ38= github.com/scanoss/ipfilter/v2 v2.0.2 h1:GaB9i8kVJg9JQZm5XGStYkEpiaCVdsrj7ezI2wV/oh8= github.com/scanoss/ipfilter/v2 v2.0.2/go.mod h1:AwrpX4XGbZ7EKISMi1d6E5csBk1nWB8+ugpvXHFcTpA= -github.com/scanoss/papi v0.25.1 h1:/OUoCWkrD+PRNvssSfrVcgPFFHKl3rCp/zjz0I2qNd8= -github.com/scanoss/papi v0.25.1/go.mod h1:Z4E/4IpwYdzHHRJXTgBCGG1GjksgrFjNW5cvhbKUfeU= +github.com/scanoss/papi v0.27.0 h1:raPAm9aFmcGVHdYZh+7su20R0ow+9tpI3jMDxxQaFUM= +github.com/scanoss/papi v0.27.0/go.mod h1:Z4E/4IpwYdzHHRJXTgBCGG1GjksgrFjNW5cvhbKUfeU= github.com/scanoss/zap-logging-helper v0.4.0 h1:2qTYoaFa9+MlD2/1wmPtiDHfh+42NIEwgKVU3rPpl0Y= github.com/scanoss/zap-logging-helper v0.4.0/go.mod h1:9QuEZcq73g/0Izv1tWeOWukoIK0oTBzM4jSNQ5kRR1w= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= diff --git a/pkg/config/server_config.go b/pkg/config/server_config.go index f60692e..3772816 100644 --- a/pkg/config/server_config.go +++ b/pkg/config/server_config.go @@ -17,6 +17,9 @@ package config import ( + "fmt" + "os" + "github.com/golobby/config/v3" "github.com/golobby/config/v3/pkg/feeder" ) @@ -68,6 +71,9 @@ type ServerConfig struct { BlockByDefault bool `env:"CRYPTO_BLOCK_BY_DEFAULT"` // Block request by default if they are not in the allow list TrustProxy bool `env:"CRYPTO_TRUST_PROXY"` // Trust the interim proxy or not (causes the source IP to be validated instead of the proxy) } + Rulesets struct { + StoragePath string `env:"RULESETS_STORAGE_PATH"` // Path to directory containing cryptography detection rulesets + } } // NewServerConfig loads all config options and return a struct for use. @@ -84,6 +90,9 @@ func NewServerConfig(feeders []config.Feeder) (*ServerConfig, error) { if err != nil { return nil, err } + if err := cfg.ValidateRulesetsFolder(); err != nil { + return nil, err + } return &cfg, nil } @@ -105,4 +114,26 @@ func setServerConfigDefaults(cfg *ServerConfig) { cfg.Logging.DynamicPort = "localhost:60054" cfg.Telemetry.Enabled = false cfg.Telemetry.OltpExporter = "0.0.0.0:4317" // Default OTEL OLTP gRPC Exporter endpoint + cfg.Rulesets.StoragePath = "/var/lib/scanoss/cryptography/rulesets" +} + +// ValidateRulesetsFolder validates that the configured rulesets storage path exists and is a directory. +func (cfg *ServerConfig) ValidateRulesetsFolder() error { + if cfg.Rulesets.StoragePath == "" { + return fmt.Errorf("rulesets storage path is not configured") + } + + info, err := os.Stat(cfg.Rulesets.StoragePath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("rulesets storage path does not exist: %s", cfg.Rulesets.StoragePath) + } + return fmt.Errorf("failed to access rulesets storage path: %w", err) + } + + if !info.IsDir() { + return fmt.Errorf("rulesets storage path is not a directory: %s", cfg.Rulesets.StoragePath) + } + + return nil } diff --git a/pkg/config/server_config_test.go b/pkg/config/server_config_test.go index ea8c68f..562eecc 100644 --- a/pkg/config/server_config_test.go +++ b/pkg/config/server_config_test.go @@ -23,14 +23,19 @@ import ( "github.com/golobby/config/v3" "github.com/golobby/config/v3/pkg/feeder" + "scanoss.com/cryptography/pkg/testutils" ) func TestServerConfig(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + dbUser := "test-user" err := os.Setenv("DB_USER", dbUser) if err != nil { t.Fatalf("an error '%s' was not expected when creating new config instance", err) } + defer os.Unsetenv("DB_USER") + cfg, err := NewServerConfig(nil) if err != nil { t.Fatalf("an error '%s' was not expected when creating new config instance", err) @@ -39,17 +44,16 @@ func TestServerConfig(t *testing.T) { t.Errorf("DB user '%v' doesn't match expected: %v", cfg.Database.User, dbUser) } fmt.Printf("Server Config1: %+v\n", cfg) - err = os.Unsetenv("DB_USER") - if err != nil { - fmt.Printf("Warning: Problem runn Unsetenv: %v\n", err) - } } func TestServerConfigDotEnv(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := os.Unsetenv("DB_USER") if err != nil { fmt.Printf("Warning: Problem runn Unsetenv: %v\n", err) } + dbUser := "env-user" var feeders []config.Feeder feeders = append(feeders, feeder.DotEnv{Path: "tests/dot-env"}) @@ -64,10 +68,13 @@ func TestServerConfigDotEnv(t *testing.T) { } func TestServerConfigJson(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := os.Unsetenv("DB_USER") if err != nil { fmt.Printf("Warning: Problem runn Unsetenv: %v\n", err) } + dbUser := "json-user" var feeders []config.Feeder feeders = append(feeders, feeder.Json{Path: "tests/env.json"}) @@ -80,3 +87,70 @@ func TestServerConfigJson(t *testing.T) { } fmt.Printf("Server Config3: %+v\n", cfg) } + +func TestValidateRulesetsFolder(t *testing.T) { + // Test with non-existent path + t.Run("NonExistentPath", func(t *testing.T) { + err := os.Setenv("RULESETS_STORAGE_PATH", "/path/that/does/not/exist") + if err != nil { + t.Fatalf("failed to set RULESETS_STORAGE_PATH: %s", err) + } + defer os.Unsetenv("RULESETS_STORAGE_PATH") + + _, err = NewServerConfig(nil) + if err == nil { + t.Fatal("expected error for non-existent rulesets path, got nil") + } + if !os.IsNotExist(err) && err.Error() != "rulesets storage path does not exist: /path/that/does/not/exist" { + t.Errorf("unexpected error message: %v", err) + } + }) + + // Test with valid directory + t.Run("ValidDirectory", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "rulesets-test-*") + if err != nil { + t.Fatalf("failed to create temp directory: %s", err) + } + defer os.RemoveAll(tmpDir) + + err = os.Setenv("RULESETS_STORAGE_PATH", tmpDir) + if err != nil { + t.Fatalf("failed to set RULESETS_STORAGE_PATH: %s", err) + } + defer os.Unsetenv("RULESETS_STORAGE_PATH") + + cfg, err := NewServerConfig(nil) + if err != nil { + t.Fatalf("unexpected error for valid rulesets path: %s", err) + } + if cfg.Rulesets.StoragePath != tmpDir { + t.Errorf("expected storage path %s, got %s", tmpDir, cfg.Rulesets.StoragePath) + } + }) + + // Test with file instead of directory + t.Run("FileInsteadOfDirectory", func(t *testing.T) { + tmpFile, err := os.CreateTemp("", "rulesets-file-*") + if err != nil { + t.Fatalf("failed to create temp file: %s", err) + } + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + err = os.Setenv("RULESETS_STORAGE_PATH", tmpFile.Name()) + if err != nil { + t.Fatalf("failed to set RULESETS_STORAGE_PATH: %s", err) + } + defer os.Unsetenv("RULESETS_STORAGE_PATH") + + _, err = NewServerConfig(nil) + if err == nil { + t.Fatal("expected error for file instead of directory, got nil") + } + expectedMsg := fmt.Sprintf("rulesets storage path is not a directory: %s", tmpFile.Name()) + if err.Error() != expectedMsg { + t.Errorf("expected error message '%s', got '%s'", expectedMsg, err.Error()) + } + }) +} diff --git a/pkg/handlers/algorithm_handler_test.go b/pkg/handlers/algorithm_handler_test.go index d848c37..00784ab 100644 --- a/pkg/handlers/algorithm_handler_test.go +++ b/pkg/handlers/algorithm_handler_test.go @@ -27,9 +27,12 @@ import ( _ "modernc.org/sqlite" myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestNewCryptographyAlgorithmHandler(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("failed to initialize logger: %v", err) @@ -57,6 +60,8 @@ func TestNewCryptographyAlgorithmHandler(t *testing.T) { } func TestCryptographyAlgorithmHandler_GetAlgorithms(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -176,6 +181,8 @@ func TestCryptographyAlgorithmHandler_GetAlgorithms(t *testing.T) { } func TestCryptographyAlgorithmHandler_GetComponentsAlgorithms(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -313,6 +320,8 @@ func TestCryptographyAlgorithmHandler_GetComponentsAlgorithms(t *testing.T) { } func TestCryptographyAlgorithmHandler_GetComponentAlgorithms(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { diff --git a/pkg/handlers/algorithm_in_range_handler_test.go b/pkg/handlers/algorithm_in_range_handler_test.go index 2a60a6d..2cc95be 100644 --- a/pkg/handlers/algorithm_in_range_handler_test.go +++ b/pkg/handlers/algorithm_in_range_handler_test.go @@ -27,9 +27,12 @@ import ( _ "modernc.org/sqlite" myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestNewAlgorithmInRangeHandler(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("failed to initialize logger: %v", err) @@ -57,6 +60,8 @@ func TestNewAlgorithmInRangeHandler(t *testing.T) { } func TestAlgorithmInRangeHandler_GetAlgorithmsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -176,6 +181,8 @@ func TestAlgorithmInRangeHandler_GetAlgorithmsInRange(t *testing.T) { } func TestAlgorithmInRangeHandler_GetComponentsAlgorithmsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -313,6 +320,8 @@ func TestAlgorithmInRangeHandler_GetComponentsAlgorithmsInRange(t *testing.T) { } func TestAlgorithmInRangeHandler_GetComponentAlgorithmsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { diff --git a/pkg/handlers/cryptography_support.go b/pkg/handlers/cryptography_support.go index 04f7ea2..48929c7 100644 --- a/pkg/handlers/cryptography_support.go +++ b/pkg/handlers/cryptography_support.go @@ -35,18 +35,21 @@ import ( // about handler performance and request processing times. type metricsCounters struct { cryptoAlgorithmsHistogram metric.Int64Histogram // Histogram for recording crypto algorithms request times in milliseconds + downloadRulesetHistogram metric.Int64Histogram // Histogram for recording ruleset download request times in milliseconds + downloadRulesetCounter metric.Int64Counter // Counter for tracking the number of downloaded rulesets } var oltpMetrics = metricsCounters{} -// setupMetrics configures all OpenTelemetry metric instruments for the handlers package. +// SetupMetrics configures all OpenTelemetry metric instruments for the handlers package. // // This function initializes histogram metrics for tracking request durations. // It should be called once during handler initialization to set up the metrics infrastructure. - func SetupMetrics() { meter := otel.Meter("scanoss.com/cryptography") oltpMetrics.cryptoAlgorithmsHistogram, _ = meter.Int64Histogram("crypto.algorithms.req_time", metric.WithDescription("The time taken to run a crypto algorithms request (ms)")) + oltpMetrics.downloadRulesetHistogram, _ = meter.Int64Histogram("crypto.rulesets.download_time", metric.WithDescription("The time taken to download a ruleset (ms)")) + oltpMetrics.downloadRulesetCounter, _ = meter.Int64Counter("crypto.rulesets.downloaded", metric.WithDescription("The number of downloaded rulesets")) } // ConvertPurlRequestToComponentDTO converts a legacy PurlRequest to ComponentDTO slice. diff --git a/pkg/handlers/encryption_hints_handler_test.go b/pkg/handlers/encryption_hints_handler_test.go index 3594e71..b03fee9 100644 --- a/pkg/handlers/encryption_hints_handler_test.go +++ b/pkg/handlers/encryption_hints_handler_test.go @@ -27,9 +27,12 @@ import ( _ "modernc.org/sqlite" myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestNewEncryptionHintsHandler(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("failed to initialize logger: %v", err) @@ -57,6 +60,8 @@ func TestNewEncryptionHintsHandler(t *testing.T) { } func TestEncryptionHintsHandler_GetEncryptionHints(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -176,6 +181,8 @@ func TestEncryptionHintsHandler_GetEncryptionHints(t *testing.T) { } func TestEncryptionHintsHandler_GetComponentsEncryptionHints(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -324,6 +331,8 @@ func TestEncryptionHintsHandler_GetComponentsEncryptionHints(t *testing.T) { } func TestEncryptionHintsHandler_GetComponentEncryptionHints(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { diff --git a/pkg/handlers/hints_in_range_handler_test.go b/pkg/handlers/hints_in_range_handler_test.go index ef7d9e0..5567e21 100644 --- a/pkg/handlers/hints_in_range_handler_test.go +++ b/pkg/handlers/hints_in_range_handler_test.go @@ -27,9 +27,12 @@ import ( _ "modernc.org/sqlite" myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestNewHintsInRangeHandler(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("failed to initialize logger: %v", err) @@ -57,6 +60,8 @@ func TestNewHintsInRangeHandler(t *testing.T) { } func TestHintsRangeHandler_GetHintsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -176,6 +181,8 @@ func TestHintsRangeHandler_GetHintsInRange(t *testing.T) { } func TestHintsRangeHandler_GetComponentsHintsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -324,6 +331,8 @@ func TestHintsRangeHandler_GetComponentsHintsInRange(t *testing.T) { } func TestHintsRangeHandler_GetComponentHintsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { diff --git a/pkg/handlers/ruleset_download_handler.go b/pkg/handlers/ruleset_download_handler.go new file mode 100644 index 0000000..12e8072 --- /dev/null +++ b/pkg/handlers/ruleset_download_handler.go @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2025 SCANOSS.COM + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package handlers + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + pb "github.com/scanoss/papi/api/cryptographyv2" + "go.uber.org/zap" + "google.golang.org/genproto/googleapis/api/httpbody" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + myconfig "scanoss.com/cryptography/pkg/config" + "scanoss.com/cryptography/pkg/httphelper" + "scanoss.com/cryptography/pkg/usecase" +) + +// RulesetDownloadHandler handles gRPC requests for downloading cryptography detection rulesets. +type RulesetDownloadHandler struct { + rulesetDownloadUseCase *usecase.RulesetDownloadUseCase + config *myconfig.ServerConfig +} + +// NewRulesetDownloadHandler creates a new RulesetDownloadHandler instance. +// +// This constructor initializes the handler with a ruleset download use case that provides +// access to the filesystem for serving ruleset tarballs. +// +// Parameters: +// - config: Server configuration including ruleset storage path +// +// Returns: +// - *RulesetDownloadHandler: Initialized handler ready to process requests +func NewRulesetDownloadHandler(config *myconfig.ServerConfig) *RulesetDownloadHandler { + return &RulesetDownloadHandler{ + config: config, + rulesetDownloadUseCase: usecase.NewRulesetDownload(config), + } +} + +// DownloadRuleset handles the download request for a specific ruleset version. +// +// This method processes a RulesetDownloadRequest containing the ruleset name and version +// (which can be "latest" or a specific version like "v1.2.3"). It validates the request, +// resolves the version, reads the metadata and tarball from the filesystem, and returns +// the tarball as a binary response with appropriate HTTP headers. +// +// The response includes custom headers: +// - Content-Type: application/gzip +// - Content-Disposition: attachment; filename="..." +// - SCANOSS-Ruleset-Name: Name of the ruleset +// - SCANOSS-Ruleset-Version: Resolved version number +// - SCANOSS-Ruleset-Created-At: Creation timestamp of the ruleset +// - SCANOSS-Ruleset-Description: Description of the ruleset (if present) +// - X-Checksum-SHA256: SHA256 checksum of the tarball +// +// Parameters: +// - ctx: Request context containing logger and trace information +// - request: RulesetDownloadRequest with ruleset name and version +// +// Returns: +// - *httpbody.HttpBody: Binary response containing the tarball +// - error: gRPC error with appropriate status code (NotFound, InvalidArgument, Internal) +func (r *RulesetDownloadHandler) DownloadRuleset(ctx context.Context, request *pb.RulesetDownloadRequest) (*httpbody.HttpBody, error) { + requestStartTime := time.Now() + s := ctxzap.Extract(ctx).Sugar() + s.Infof("Processing ruleset download request: %s/%s", request.RulesetName, request.Version) + + if err := r.validateRequest(request); err != nil { + s.Warnf("Invalid ruleset download request: %v", err) + httphelper.SetHTTPCodeOnTrailer(ctx, s, http.StatusBadRequest) + return nil, status.Errorf(codes.InvalidArgument, "invalid request: %v", err) + } + + ruleset, err := r.rulesetDownloadUseCase.DownloadRuleset(ctx, s, request.RulesetName, request.Version) + if err != nil { + return r.handleUseCaseError(ctx, s, err) + } + + filename := fmt.Sprintf("%s-%s.tar.gz", ruleset.Metadata.Name, ruleset.Metadata.Version) + headers := []string{ + "content-type", "application/gzip", + "content-disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename), + "scanoss-ruleset-name", ruleset.Metadata.Name, + "scanoss-ruleset-version", ruleset.Metadata.Version, + "scanoss-ruleset-created-at", ruleset.Metadata.CreatedAt, + "x-checksum-sha256", ruleset.Metadata.ChecksumSHA256, + } + + if strings.TrimSpace(ruleset.Metadata.Description) != "" { + headers = append(headers, "scanoss-ruleset-description", ruleset.Metadata.Description) + } + + md := metadata.Pairs(headers...) + + if err := grpc.SendHeader(ctx, md); err != nil { + s.Errorf("Failed to send response headers: %v", err) + httphelper.SetHTTPCodeOnTrailer(ctx, s, http.StatusInternalServerError) + return nil, status.Errorf(codes.Internal, "failed to send response headers") + } + + response := &httpbody.HttpBody{ + ContentType: "application/gzip", + Data: ruleset.TarballData, + } + + telemetryDownloadRulesetRequestTime(ctx, r.config, requestStartTime) + telemetryAddRulesetDownloaded(ctx, r.config) + + s.Infof("Successfully served ruleset: %s/%s (%d bytes)", request.RulesetName, request.Version, len(ruleset.TarballData)) + return response, nil +} + +// validateRequest validates the RulesetDownloadRequest. +func (r *RulesetDownloadHandler) validateRequest(request *pb.RulesetDownloadRequest) error { + if request == nil { + return fmt.Errorf("request cannot be empty") + } + + if strings.TrimSpace(request.RulesetName) == "" { + return fmt.Errorf("ruleset_name cannot be empty") + } + + if strings.TrimSpace(request.Version) == "" { + return fmt.Errorf("version cannot be empty, you must provide a specific version or 'latest'") + } + + version := strings.TrimSpace(request.Version) + _, err := semver.NewVersion(version) + if err != nil && version != "latest" { + return fmt.Errorf("version must be 'latest' or a valid semver string (e.g., 'v1.2.3')") + } + + return nil +} + +// handleUseCaseError converts use case errors to appropriate gRPC errors with HTTP status codes. +func (r *RulesetDownloadHandler) handleUseCaseError(ctx context.Context, s *zap.SugaredLogger, err error) (*httpbody.HttpBody, error) { + errMsg := err.Error() + + s.Errorf("Error while downloading ruleset: %v", err) + + switch { + case strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "does not exist"): + httphelper.SetHTTPCodeOnTrailer(ctx, s, http.StatusNotFound) + return nil, status.Errorf(codes.NotFound, "requested ruleset or version not found") + case strings.Contains(errMsg, "integrity check failed") || strings.Contains(errMsg, "checksum"): + httphelper.SetHTTPCodeOnTrailer(ctx, s, http.StatusInternalServerError) + return nil, status.Errorf(codes.DataLoss, "ruleset integrity verification failed") + default: + httphelper.SetHTTPCodeOnTrailer(ctx, s, http.StatusInternalServerError) + return nil, status.Errorf(codes.Internal, "internal server error") + } +} diff --git a/pkg/handlers/ruleset_download_handler_test.go b/pkg/handlers/ruleset_download_handler_test.go new file mode 100644 index 0000000..28227f7 --- /dev/null +++ b/pkg/handlers/ruleset_download_handler_test.go @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2025 SCANOSS.COM + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package handlers + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + pb "github.com/scanoss/papi/api/cryptographyv2" + zlog "github.com/scanoss/zap-logging-helper/pkg/logger" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + myconfig "scanoss.com/cryptography/pkg/config" +) + +func TestNewRulesetDownloadHandler(t *testing.T) { + err := zlog.NewSugaredDevLogger() + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + defer zlog.SyncZap() + + config := &myconfig.ServerConfig{} + config.Rulesets.StoragePath = "/tmp/rulesets" + + handler := NewRulesetDownloadHandler(config) + if handler == nil { + t.Error("NewRulesetDownloadHandler() returned nil") + } + if handler.config == nil { + t.Error("NewRulesetDownloadHandler() config is nil") + } + if handler.rulesetDownloadUseCase == nil { + t.Error("NewRulesetDownloadHandler() rulesetDownloadUseCase is nil") + } +} + +func TestRulesetDownloadHandler_validateRequest(t *testing.T) { + err := zlog.NewSugaredDevLogger() + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + defer zlog.SyncZap() + + config := &myconfig.ServerConfig{} + config.Rulesets.StoragePath = "/tmp/rulesets" + + handler := NewRulesetDownloadHandler(config) + + tests := []struct { + name string + request *pb.RulesetDownloadRequest + expectError bool + errorContains string + }{ + { + name: "valid request with specific version", + request: &pb.RulesetDownloadRequest{ + RulesetName: "dca", + Version: "v1.0.0", + }, + expectError: false, + }, + { + name: "valid request with latest", + request: &pb.RulesetDownloadRequest{ + RulesetName: "dca", + Version: "latest", + }, + expectError: false, + }, + { + name: "nil request", + request: nil, + expectError: true, + errorContains: "cannot be empty", + }, + { + name: "empty ruleset name", + request: &pb.RulesetDownloadRequest{ + RulesetName: "", + Version: "v1.0.0", + }, + expectError: true, + errorContains: "ruleset_name cannot be empty", + }, + { + name: "empty version", + request: &pb.RulesetDownloadRequest{ + RulesetName: "dca", + Version: "", + }, + expectError: true, + errorContains: "version cannot be empty", + }, + { + name: "whitespace only ruleset name", + request: &pb.RulesetDownloadRequest{ + RulesetName: " ", + Version: "v1.0.0", + }, + expectError: true, + errorContains: "ruleset_name cannot be empty", + }, + { + name: "whitespace only version", + request: &pb.RulesetDownloadRequest{ + RulesetName: "dca", + Version: " ", + }, + expectError: true, + errorContains: "version cannot be empty", + }, + { + name: "invalid semver version", + request: &pb.RulesetDownloadRequest{ + RulesetName: "dca", + Version: "not-a-version", + }, + expectError: true, + errorContains: "valid semver", + }, + { + name: "valid semver without v prefix", + request: &pb.RulesetDownloadRequest{ + RulesetName: "dca", + Version: "1.0.0", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := handler.validateRequest(tt.request) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error containing '%s', got nil", tt.errorContains) + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + } + }) + } +} + +func TestRulesetDownloadHandler_DownloadRuleset(t *testing.T) { + err := zlog.NewSugaredDevLogger() + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + defer zlog.SyncZap() + + // Get the project root directory + projectRoot, err := filepath.Abs("../../") + if err != nil { + t.Fatalf("failed to get project root: %v", err) + } + testStoragePath := filepath.Join(projectRoot, "test-support", "rulesets") + + config := &myconfig.ServerConfig{} + config.Rulesets.StoragePath = testStoragePath + + handler := NewRulesetDownloadHandler(config) + ctx := context.Background() + ctx = ctxzap.ToContext(ctx, zlog.L) + + tests := []struct { + name string + request *pb.RulesetDownloadRequest + expectError bool + expectedCode codes.Code + errorContains string + validateResult func(t *testing.T, contentType string, dataLen int) + }{ + { + name: "invalid request - empty ruleset name", + request: &pb.RulesetDownloadRequest{ + RulesetName: "", + Version: "v1.0.0", + }, + expectError: true, + expectedCode: codes.InvalidArgument, + errorContains: "invalid request", + }, + { + name: "invalid request - empty version", + request: &pb.RulesetDownloadRequest{ + RulesetName: "dca", + Version: "", + }, + expectError: true, + expectedCode: codes.InvalidArgument, + errorContains: "invalid request", + }, + { + name: "ruleset not found", + request: &pb.RulesetDownloadRequest{ + RulesetName: "nonexistent-ruleset", + Version: "v1.0.0", + }, + expectError: true, + expectedCode: codes.NotFound, + errorContains: "not found", + }, + { + name: "version not found", + request: &pb.RulesetDownloadRequest{ + RulesetName: "dca", + Version: "v99.99.99", + }, + expectError: true, + expectedCode: codes.NotFound, + errorContains: "not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := handler.DownloadRuleset(ctx, tt.request) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + return + } + + st, ok := status.FromError(err) + if !ok { + t.Errorf("Expected gRPC status error, got: %v", err) + return + } + + if st.Code() != tt.expectedCode { + t.Errorf("Expected code %v, got %v", tt.expectedCode, st.Code()) + } + + if tt.errorContains != "" && !strings.Contains(st.Message(), tt.errorContains) { + t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, st.Message()) + } + } else { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + return + } + + if result == nil { + t.Error("Expected non-nil result") + return + } + + if tt.validateResult != nil { + tt.validateResult(t, result.ContentType, len(result.Data)) + } + } + }) + } +} + +func TestRulesetDownloadHandler_handleUseCaseError(t *testing.T) { + err := zlog.NewSugaredDevLogger() + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + defer zlog.SyncZap() + + config := &myconfig.ServerConfig{} + config.Rulesets.StoragePath = "/tmp/rulesets" + + handler := NewRulesetDownloadHandler(config) + ctx := context.Background() + ctx = ctxzap.ToContext(ctx, zlog.L) + s := zlog.S + + tests := []struct { + name string + inputError error + expectedCode codes.Code + errorPattern string + }{ + { + name: "not found error", + inputError: &customError{msg: "ruleset not found"}, + expectedCode: codes.NotFound, + errorPattern: "requested ruleset or version not found", + }, + { + name: "does not exist error", + inputError: &customError{msg: "file does not exist"}, + expectedCode: codes.NotFound, + errorPattern: "requested ruleset or version not found", + }, + { + name: "failed to resolve error", + inputError: &customError{msg: "failed to resolve version"}, + expectedCode: codes.Internal, + errorPattern: "internal server error", + }, + { + name: "failed to read error", + inputError: &customError{msg: "failed to read metadata"}, + expectedCode: codes.Internal, + errorPattern: "internal server error", + }, + { + name: "failed to parse error", + inputError: &customError{msg: "failed to parse manifest"}, + expectedCode: codes.Internal, + errorPattern: "internal server error", + }, + { + name: "integrity check failed error", + inputError: &customError{msg: "tarball integrity check failed"}, + expectedCode: codes.DataLoss, + errorPattern: "ruleset integrity verification failed", + }, + { + name: "checksum error", + inputError: &customError{msg: "checksum mismatch"}, + expectedCode: codes.DataLoss, + errorPattern: "ruleset integrity verification failed", + }, + { + name: "unexpected error", + inputError: &customError{msg: "some random error"}, + expectedCode: codes.Internal, + errorPattern: "internal server error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := handler.handleUseCaseError(ctx, s, tt.inputError) + + if err == nil { + t.Error("Expected error, got nil") + return + } + + if result != nil { + t.Errorf("Expected nil result, got: %v", result) + } + + st, ok := status.FromError(err) + if !ok { + t.Errorf("Expected gRPC status error, got: %v", err) + return + } + + if st.Code() != tt.expectedCode { + t.Errorf("Expected code %v, got %v", tt.expectedCode, st.Code()) + } + + if !strings.Contains(st.Message(), tt.errorPattern) { + t.Errorf("Expected error message to contain '%s', got '%s'", tt.errorPattern, st.Message()) + } + }) + } +} + +// customError is a helper type for testing error handling +type customError struct { + msg string +} + +func (e *customError) Error() string { + return e.msg +} diff --git a/pkg/handlers/utils_handler.go b/pkg/handlers/utils_handler.go index ff6b54c..3ac8fc0 100644 --- a/pkg/handlers/utils_handler.go +++ b/pkg/handlers/utils_handler.go @@ -39,3 +39,34 @@ func telemetryRequestTime(ctx context.Context, config *myconfig.ServerConfig, re oltpMetrics.cryptoAlgorithmsHistogram.Record(ctx, elapsedTime) // Record algorithm request time } } + +// telemetryDownloadRulesetRequestTime records download ruleset request duration to OpenTelemetry. +// +// This function calculates the elapsed time since the request started and records it +// to the OpenTelemetry histogram for performance monitoring. Recording only occurs +// if telemetry is enabled in the server configuration. +// +// Parameters: +// - ctx: Context for the telemetry recording +// - config: Server configuration containing telemetry settings +// - requestStartTime: Time when the request processing began +func telemetryDownloadRulesetRequestTime(ctx context.Context, config *myconfig.ServerConfig, requestStartTime time.Time) { + if config.Telemetry.Enabled { + elapsedTime := time.Since(requestStartTime).Milliseconds() // Time taken to download ruleset request + oltpMetrics.downloadRulesetHistogram.Record(ctx, elapsedTime) // Record download ruleset request time + } +} + +// telemetryAddRulesetDownloaded adds a record for downloaded rulesets to OpenTelemetry. +// +// This function increments the counter for downloaded rulesets in the OpenTelemetry metrics. +// Recording only occurs if telemetry is enabled in the server configuration. +// +// Parameters: +// - ctx: Context for the telemetry recording +// - config: Server configuration containing telemetry settings +func telemetryAddRulesetDownloaded(ctx context.Context, config *myconfig.ServerConfig) { + if config.Telemetry.Enabled { + oltpMetrics.downloadRulesetCounter.Add(ctx, 1) + } +} diff --git a/pkg/handlers/versions_in_range_handler_test.go b/pkg/handlers/versions_in_range_handler_test.go index b2c28c1..6d0375f 100644 --- a/pkg/handlers/versions_in_range_handler_test.go +++ b/pkg/handlers/versions_in_range_handler_test.go @@ -27,9 +27,12 @@ import ( _ "modernc.org/sqlite" myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestNewVersionsInRangeHandler(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("failed to initialize logger: %v", err) @@ -57,6 +60,8 @@ func TestNewVersionsInRangeHandler(t *testing.T) { } func TestVersionsInRangeHandler_GetVersionsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -176,6 +181,8 @@ func TestVersionsInRangeHandler_GetVersionsInRange(t *testing.T) { } func TestVersionsInRangeHandler_GetComponentsVersionsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -313,6 +320,8 @@ func TestVersionsInRangeHandler_GetComponentsVersionsInRange(t *testing.T) { } func TestVersionsInRangeHandler_GetComponentVersionsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { diff --git a/pkg/models/all_urls_test.go b/pkg/models/all_urls_test.go index 5061fe4..de2ff0b 100644 --- a/pkg/models/all_urls_test.go +++ b/pkg/models/all_urls_test.go @@ -25,9 +25,12 @@ import ( zlog "github.com/scanoss/zap-logging-helper/pkg/logger" myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/utils" + "scanoss.com/cryptography/pkg/testutils" ) func TestAllUrlsSearchVersion(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -90,6 +93,8 @@ func TestAllUrlsSearchVersion(t *testing.T) { } } func TestAllUrlsSearchVersionRequirement(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -128,6 +133,8 @@ func TestAllUrlsSearchVersionRequirement(t *testing.T) { } func TestAllUrlsSearchVersionRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -173,6 +180,8 @@ func TestAllUrlsSearchVersionRange(t *testing.T) { } func TestAllUrlsSearchPurlList(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -203,6 +212,8 @@ func TestAllUrlsSearchPurlList(t *testing.T) { } func TestAllUrlsClosestVersionRequirement(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -232,6 +243,8 @@ func TestAllUrlsClosestVersionRequirement(t *testing.T) { } func TestAllUrlsSearchNoLicense(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -262,6 +275,8 @@ func TestAllUrlsSearchNoLicense(t *testing.T) { } func TestAllUrlsSearchBadSql(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) diff --git a/pkg/models/common_test.go b/pkg/models/common_test.go index f9e2fb3..0e58906 100644 --- a/pkg/models/common_test.go +++ b/pkg/models/common_test.go @@ -18,11 +18,11 @@ package models import ( "context" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - zlog "github.com/scanoss/zap-logging-helper/pkg/logger" "testing" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "github.com/jmoiron/sqlx" + zlog "github.com/scanoss/zap-logging-helper/pkg/logger" _ "modernc.org/sqlite" ) diff --git a/pkg/models/crypto_usage_test.go b/pkg/models/crypto_usage_test.go index ae327b2..0362402 100644 --- a/pkg/models/crypto_usage_test.go +++ b/pkg/models/crypto_usage_test.go @@ -24,9 +24,12 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" zlog "github.com/scanoss/zap-logging-helper/pkg/logger" myconfig "scanoss.com/cryptography/pkg/config" + "scanoss.com/cryptography/pkg/testutils" ) func TestCryptoSearchUsageByList(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) diff --git a/pkg/models/library_usage_test.go b/pkg/models/library_usage_test.go index 32e621f..cc62b46 100644 --- a/pkg/models/library_usage_test.go +++ b/pkg/models/library_usage_test.go @@ -23,9 +23,12 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" zlog "github.com/scanoss/zap-logging-helper/pkg/logger" myconfig "scanoss.com/cryptography/pkg/config" + "scanoss.com/cryptography/pkg/testutils" ) func TestECSearchUsage(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) diff --git a/pkg/service/cryptography_service.go b/pkg/service/cryptography_service.go index 8272eb9..f9bea06 100644 --- a/pkg/service/cryptography_service.go +++ b/pkg/service/cryptography_service.go @@ -24,6 +24,7 @@ import ( "github.com/jmoiron/sqlx" common "github.com/scanoss/papi/api/commonv2" pb "github.com/scanoss/papi/api/cryptographyv2" + "google.golang.org/genproto/googleapis/api/httpbody" myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/handlers" ) @@ -40,18 +41,21 @@ type cryptographyServer struct { versionsInRangeHandler *handlers.VersionsInRangeHandler hintsInRangeHandler *handlers.HintsRangeHandler encryptionHintsHandler *handlers.EncryptionHintsHandler + rulesetDownloadHandler *handlers.RulesetDownloadHandler } // NewCryptographyServer creates a new instance of Cryptography Server. func NewCryptographyServer(db *sqlx.DB, config *myconfig.ServerConfig) pb.CryptographyServer { // Setups metrics handlers.SetupMetrics() - return &cryptographyServer{db: db, config: config, + return &cryptographyServer{ + db: db, config: config, algorithmHandler: handlers.NewCryptographyAlgorithmHandler(db, config), algorithmInRangeHandler: handlers.NewAlgorithmInRangeHandler(db, config), versionsInRangeHandler: handlers.NewVersionsInRangeHandler(db, config), hintsInRangeHandler: handlers.NewHintsInRangeHandler(db, config), encryptionHintsHandler: handlers.NewEncryptionHintsHandler(db, config), + rulesetDownloadHandler: handlers.NewRulesetDownloadHandler(config), } } @@ -146,3 +150,8 @@ func (c cryptographyServer) GetComponentsEncryptionHints(ctx context.Context, re func (c cryptographyServer) GetComponentEncryptionHints(ctx context.Context, request *common.ComponentRequest) (*pb.ComponentEncryptionHintsResponse, error) { return c.encryptionHintsHandler.GetComponentEncryptionHints(ctx, request) } + +// DownloadRuleset serves cryptography detection ruleset tarballs for download. +func (c cryptographyServer) DownloadRuleset(ctx context.Context, request *pb.RulesetDownloadRequest) (*httpbody.HttpBody, error) { + return c.rulesetDownloadHandler.DownloadRuleset(ctx, request) +} diff --git a/pkg/service/cryptography_service_test.go b/pkg/service/cryptography_service_test.go index 8743688..df84651 100644 --- a/pkg/service/cryptography_service_test.go +++ b/pkg/service/cryptography_service_test.go @@ -33,9 +33,12 @@ import ( _ "modernc.org/sqlite" myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestCryptographyServer_Echo(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -95,6 +98,8 @@ func TestCryptographyServer_Echo(t *testing.T) { } func TestCryptographyServer_GetAlgorithms(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -204,6 +209,8 @@ func TestCryptographyServer_GetAlgorithms(t *testing.T) { } func TestCryptographyServer_GetAlgorithmsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() @@ -258,6 +265,8 @@ func TestCryptographyServer_GetAlgorithmsInRange(t *testing.T) { } func TestCryptographyServer_GetVersionsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -311,6 +320,8 @@ func TestCryptographyServer_GetVersionsInRange(t *testing.T) { } func TestCryptographyServer_GetHintsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() @@ -365,6 +376,8 @@ func TestCryptographyServer_GetHintsInRange(t *testing.T) { } func TestCryptographyServer_GetHints(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -414,6 +427,8 @@ func TestCryptographyServer_GetHints(t *testing.T) { } func TestCryptographyServer_GetComponentsAlgorithms(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -529,6 +544,8 @@ func TestCryptographyServer_GetComponentsAlgorithms(t *testing.T) { } func TestCryptographyServer_GetComponentAlgorithms(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -620,6 +637,8 @@ func TestCryptographyServer_GetComponentAlgorithms(t *testing.T) { } func TestCryptographyServer_GetComponentsAlgorithmsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -735,6 +754,8 @@ func TestCryptographyServer_GetComponentsAlgorithmsInRange(t *testing.T) { } func TestCryptographyServer_GetComponentAlgorithmsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -824,6 +845,8 @@ func TestCryptographyServer_GetComponentAlgorithmsInRange(t *testing.T) { } func TestCryptographyServer_GetComponentVersionsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -898,6 +921,8 @@ func TestCryptographyServer_GetComponentVersionsInRange(t *testing.T) { } func TestCryptographyServer_GetComponentsVersionsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -989,6 +1014,8 @@ func TestCryptographyServer_GetComponentsVersionsInRange(t *testing.T) { } func TestCryptographyServer_GetComponentHintsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -1100,6 +1127,8 @@ func TestCryptographyServer_GetComponentHintsInRange(t *testing.T) { } func TestCryptographyServer_GetComponentsHintsInRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -1220,6 +1249,8 @@ func TestCryptographyServer_GetComponentsHintsInRange(t *testing.T) { } func TestCryptographyServer_GetComponentsEncryptionHints(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { @@ -1331,6 +1362,8 @@ func TestCryptographyServer_GetComponentsEncryptionHints(t *testing.T) { } func TestCryptographyServer_GetComponentEncryptionHints(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + ctx := context.Background() err := zlog.NewSugaredDevLogger() if err != nil { diff --git a/pkg/testutils/test_helpers.go b/pkg/testutils/test_helpers.go new file mode 100644 index 0000000..0d22731 --- /dev/null +++ b/pkg/testutils/test_helpers.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2025 SCANOSS.COM + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package testutils + +import ( + "os" + "testing" +) + +// SetupTestRulesetsDir creates a temporary rulesets directory for testing +// and sets the RULESETS_STORAGE_PATH environment variable. +// Returns a cleanup function that should be deferred. +// +// Usage: +// func TestMyFunction(t *testing.T) { +// defer testutils.SetupTestRulesetsDir(t)() +// // ... test code +// } +func SetupTestRulesetsDir(t *testing.T) func() { + tmpDir, err := os.MkdirTemp("", "rulesets-test-*") + if err != nil { + t.Fatalf("failed to create temp rulesets directory: %v", err) + } + + err = os.Setenv("RULESETS_STORAGE_PATH", tmpDir) + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("failed to set RULESETS_STORAGE_PATH: %v", err) + } + + return func() { + os.Unsetenv("RULESETS_STORAGE_PATH") + os.RemoveAll(tmpDir) + } +} diff --git a/pkg/usecase/cryptography_major_test.go b/pkg/usecase/cryptography_major_test.go index 292dc65..df646df 100644 --- a/pkg/usecase/cryptography_major_test.go +++ b/pkg/usecase/cryptography_major_test.go @@ -28,9 +28,12 @@ import ( myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/dtos" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestAlgorithmsInRangeUseCase(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) diff --git a/pkg/usecase/cryptography_search_test.go b/pkg/usecase/cryptography_search_test.go index 3d33676..0bd9153 100644 --- a/pkg/usecase/cryptography_search_test.go +++ b/pkg/usecase/cryptography_search_test.go @@ -28,9 +28,12 @@ import ( myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/dtos" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestCryptographyUseCase(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) diff --git a/pkg/usecase/cryptography_versions_using_test.go b/pkg/usecase/cryptography_versions_using_test.go index add8bb1..5a0853a 100644 --- a/pkg/usecase/cryptography_versions_using_test.go +++ b/pkg/usecase/cryptography_versions_using_test.go @@ -29,9 +29,12 @@ import ( myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/dtos" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestVersionsUsingCryptoUseCase(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -161,6 +164,8 @@ func TestVersionsUsingCryptoUseCase(t *testing.T) { } func TestVersionInRangeUsingCryptoUseCase(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) diff --git a/pkg/usecase/library_detections_test.go b/pkg/usecase/library_detections_test.go index e94064e..594105a 100644 --- a/pkg/usecase/library_detections_test.go +++ b/pkg/usecase/library_detections_test.go @@ -28,9 +28,12 @@ import ( myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/dtos" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/testutils" ) func TestLibrariesDetectionUseCase_InRange(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -103,6 +106,8 @@ func TestLibrariesDetectionUseCase_InRange(t *testing.T) { } func TestLibrariesDetectionUseCase_ExactVersion(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) @@ -184,6 +189,8 @@ func TestLibrariesDetectionUseCase_ExactVersion(t *testing.T) { } } func TestLibrariesDetectionUseCase_MalformedPurl(t *testing.T) { + defer testutils.SetupTestRulesetsDir(t)() + err := zlog.NewSugaredDevLogger() if err != nil { t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) diff --git a/pkg/usecase/ruleset_download.go b/pkg/usecase/ruleset_download.go new file mode 100644 index 0000000..076cc6b --- /dev/null +++ b/pkg/usecase/ruleset_download.go @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2025 SCANOSS.COM + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "go.uber.org/zap" + myconfig "scanoss.com/cryptography/pkg/config" +) + +// RulesetMetadata represents the manifest.json structure. +type RulesetMetadata struct { + ChecksumSHA256 string `json:"checksum_sha256"` + CreatedAt string `json:"created_at"` + Description string `json:"description,omitempty"` + Name string `json:"name"` + Version string `json:"version"` +} + +// RulesetDownloadOutput contains the tarball data and metadata. +type RulesetDownloadOutput struct { + Metadata RulesetMetadata + TarballData []byte +} + +// RulesetDownloadUseCase handles the business logic for downloading rulesets. +type RulesetDownloadUseCase struct { + config *myconfig.ServerConfig +} + +// NewRulesetDownload creates a new RulesetDownloadUseCase. +func NewRulesetDownload(config *myconfig.ServerConfig) *RulesetDownloadUseCase { + return &RulesetDownloadUseCase{ + config: config, + } +} + +// verifyPathContainment ensures the resolved path is contained within the base directory. +// This prevents path traversal attacks where filepath.Join could resolve .. segments to escape. +func (r *RulesetDownloadUseCase) verifyPathContainment(resolvedPath, baseDir string) error { + absBase, err := filepath.Abs(baseDir) + if err != nil { + return fmt.Errorf("failed to resolve base directory: %w", err) + } + + absResolved, err := filepath.Abs(resolvedPath) + if err != nil { + return fmt.Errorf("failed to resolve target path: %w", err) + } + + // Ensure the resolved path starts with the base directory followed by a separator + // This prevents cases where "/base/dir" would match "/base/dir-malicious" + if !strings.HasPrefix(absResolved, absBase+string(filepath.Separator)) && absResolved != absBase { + return fmt.Errorf("path escapes base directory") + } + + return nil +} + +// resolveVersion resolves a version string (potentially "latest") to an actual version directory path +// It handles symlink resolution for "latest" version. +func (r *RulesetDownloadUseCase) resolveVersion(s *zap.SugaredLogger, rulesetName, version string) (string, error) { + basePath := filepath.Join(r.config.Rulesets.StoragePath, rulesetName) + + // Verify the resolved path is contained within the storage directory + if err := r.verifyPathContainment(basePath, r.config.Rulesets.StoragePath); err != nil { + return "", fmt.Errorf("invalid ruleset name: %w", err) + } + + if _, err := os.Stat(basePath); os.IsNotExist(err) { + s.Warnf("Ruleset directory does not exist: %s", basePath) + return "", fmt.Errorf("ruleset '%s' not found", rulesetName) + } + + versionPath := filepath.Join(basePath, version) + + // If version is "latest", it should be a symlink + if version == "latest" { + targetPath, err := os.Readlink(versionPath) + if err != nil { + s.Warnf("Failed to read 'latest' symlink for ruleset %s: %v", rulesetName, err) + return "", fmt.Errorf("failed to resolve 'latest' version for ruleset '%s'", rulesetName) + } + + // If targetPath is relative, resolve it relative to basePath + if !filepath.IsAbs(targetPath) { + versionPath = filepath.Join(basePath, targetPath) + } else { + versionPath = targetPath + } + s.Debugf("Resolved 'latest' symlink for %s to: %s", rulesetName, versionPath) + + // Verify the resolved symlink target is contained within the storage directory + if err := r.verifyPathContainment(versionPath, r.config.Rulesets.StoragePath); err != nil { + s.Warnf("Symlink target escapes storage directory: %s", versionPath) + return "", fmt.Errorf("invalid symlink target: %w", err) + } + } + + if _, err := os.Stat(versionPath); os.IsNotExist(err) { + s.Warnf("Version directory does not exist: %s", versionPath) + return "", fmt.Errorf("version '%s' not found for ruleset '%s'", version, rulesetName) + } + + return versionPath, nil +} + +// readMetadata reads and parses the manifest.json file from the version directory. +func (r *RulesetDownloadUseCase) readMetadata(s *zap.SugaredLogger, versionPath string) (RulesetMetadata, error) { + metadataPath := filepath.Join(versionPath, "manifest.json") + + s.Debugf("Reading metadata from: %s", metadataPath) + + data, err := os.ReadFile(metadataPath) + if err != nil { + s.Warnf("Failed to read metadata file %s: %v", metadataPath, err) + return RulesetMetadata{}, fmt.Errorf("failed to read metadata: %w", err) + } + + var metadata RulesetMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + s.Warnf("Failed to parse metadata JSON from %s: %v", metadataPath, err) + return RulesetMetadata{}, fmt.Errorf("failed to parse metadata: %w", err) + } + + s.Debugf("Successfully parsed metadata: name=%s, version=%s, checksum=%s", + metadata.Name, metadata.Version, metadata.ChecksumSHA256) + + return metadata, nil +} + +// readTarball reads the tarball file into memory. +func (r *RulesetDownloadUseCase) readTarball(s *zap.SugaredLogger, versionPath, rulesetName, rulesetVersion string) ([]byte, error) { + fileName := fmt.Sprintf("%s-%s.tar.gz", rulesetName, rulesetVersion) + tarballPath := filepath.Join(versionPath, fileName) + + s.Debugf("Reading tarball from: %s", tarballPath) + + fileInfo, err := os.Stat(tarballPath) + if err != nil { + s.Warnf("Failed to stat tarball file %s: %v", tarballPath, err) + return nil, fmt.Errorf("tarball file not found: %w", err) + } + + s.Debugf("Tarball file size: %d bytes", fileInfo.Size()) + + data, err := os.ReadFile(tarballPath) + if err != nil { + s.Warnf("Failed to read tarball file %s: %v", tarballPath, err) + return nil, fmt.Errorf("failed to read tarball: %w", err) + } + + s.Debugf("Successfully read %d bytes from tarball", len(data)) + + return data, nil +} + +// verifyTarballChecksum validates the SHA256 checksum of the tarball data against the expected checksum. +// It returns an error if the checksum is empty, mismatched, or if there's any validation failure. +func (r *RulesetDownloadUseCase) verifyTarballChecksum(s *zap.SugaredLogger, tarballData []byte, expectedChecksum string) error { + // Check if expected checksum is empty + if strings.TrimSpace(expectedChecksum) == "" { + return fmt.Errorf("expected checksum is empty, cannot validate tarball integrity") + } + + // Compute SHA256 of the tarball data + hash := sha256.Sum256(tarballData) + actualChecksum := hex.EncodeToString(hash[:]) + + s.Debugf("Tarball checksum validation: expected=%s, actual=%s", expectedChecksum, actualChecksum) + + // Compare checksums + if actualChecksum != strings.ToLower(expectedChecksum) { + s.Warnf("Checksum mismatch! Expected: %s, Actual: %s", expectedChecksum, actualChecksum) + return fmt.Errorf("tarball integrity check failed") + } + + s.Debugf("Tarball checksum validation passed") + return nil +} + +// DownloadRuleset orchestrates the entire ruleset download process. +func (r *RulesetDownloadUseCase) DownloadRuleset(ctx context.Context, s *zap.SugaredLogger, rulesetName, version string) (RulesetDownloadOutput, error) { + if err := ctx.Err(); err != nil { + return RulesetDownloadOutput{}, fmt.Errorf("request cancelled: %w", err) + } + + resolvedVersionPath, err := r.resolveVersion(s, rulesetName, version) + if err != nil { + return RulesetDownloadOutput{}, err + } + + metadata, err := r.readMetadata(s, resolvedVersionPath) + if err != nil { + return RulesetDownloadOutput{}, err + } + + tarballData, err := r.readTarball(s, resolvedVersionPath, metadata.Name, metadata.Version) + if err != nil { + return RulesetDownloadOutput{}, err + } + + if err := r.verifyTarballChecksum(s, tarballData, metadata.ChecksumSHA256); err != nil { + return RulesetDownloadOutput{}, fmt.Errorf("tarball integrity check failed: %w", err) + } + + return RulesetDownloadOutput{ + TarballData: tarballData, + Metadata: metadata, + }, nil +} diff --git a/pkg/usecase/ruleset_download_test.go b/pkg/usecase/ruleset_download_test.go new file mode 100644 index 0000000..1d3b55f --- /dev/null +++ b/pkg/usecase/ruleset_download_test.go @@ -0,0 +1,466 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2025 SCANOSS.COM + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package usecase + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + zlog "github.com/scanoss/zap-logging-helper/pkg/logger" + myconfig "scanoss.com/cryptography/pkg/config" +) + +func TestNewRulesetDownload(t *testing.T) { + config := &myconfig.ServerConfig{} + config.Rulesets.StoragePath = "/tmp/rulesets" + + usecase := NewRulesetDownload(config) + if usecase == nil { + t.Error("NewRulesetDownload() returned nil") + } + if usecase.config == nil { + t.Error("NewRulesetDownload() config is nil") + } + if usecase.config.Rulesets.StoragePath != "/tmp/rulesets" { + t.Errorf("Expected storage path '/tmp/rulesets', got '%s'", usecase.config.Rulesets.StoragePath) + } +} + +func TestRulesetDownloadUseCase_DownloadRuleset(t *testing.T) { + // Initialize logger + err := zlog.NewSugaredDevLogger() + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + defer zlog.SyncZap() + + // Get the project root directory (assuming tests run from project root or pkg/usecase) + projectRoot, err := filepath.Abs("../../") + if err != nil { + t.Fatalf("failed to get project root: %v", err) + } + testStoragePath := filepath.Join(projectRoot, "test-support", "rulesets") + + // Verify test data exists + if _, err := os.Stat(testStoragePath); os.IsNotExist(err) { + t.Skipf("Test data not found at %s, skipping tests", testStoragePath) + } + + config := &myconfig.ServerConfig{} + config.Rulesets.StoragePath = testStoragePath + + usecase := NewRulesetDownload(config) + ctx := context.Background() + s := zlog.S + + tests := []struct { + name string + rulesetName string + version string + expectError bool + errorContains string + validateResult func(t *testing.T, result RulesetDownloadOutput) + }{ + { + name: "download specific version - dca v1.0.1", + rulesetName: "dca", + version: "v1.0.1", + expectError: false, + validateResult: func(t *testing.T, result RulesetDownloadOutput) { + if result.Metadata.Name != "dca" { + t.Errorf("Expected name 'dca', got '%s'", result.Metadata.Name) + } + if result.Metadata.Version != "v1.0.1" { + t.Errorf("Expected version 'v1.0.1', got '%s'", result.Metadata.Version) + } + if len(result.TarballData) == 0 { + t.Error("Expected non-empty tarball data") + } + if result.Metadata.ChecksumSHA256 == "" { + t.Error("Expected non-empty checksum") + } + }, + }, + { + name: "download specific version - dca v1.0.0", + rulesetName: "dca", + version: "v1.0.0", + expectError: false, + validateResult: func(t *testing.T, result RulesetDownloadOutput) { + if result.Metadata.Name != "dca" { + t.Errorf("Expected name 'dca', got '%s'", result.Metadata.Name) + } + if result.Metadata.Version != "v1.0.0" { + t.Errorf("Expected version 'v1.0.0', got '%s'", result.Metadata.Version) + } + if len(result.TarballData) == 0 { + t.Error("Expected non-empty tarball data") + } + }, + }, + { + name: "download latest version", + rulesetName: "dca", + version: "latest", + expectError: false, + validateResult: func(t *testing.T, result RulesetDownloadOutput) { + if result.Metadata.Name != "dca" { + t.Errorf("Expected name 'dca', got '%s'", result.Metadata.Name) + } + // The latest symlink should point to v1.0.1 + if result.Metadata.Version != "v1.0.1" { + t.Errorf("Expected latest version to be 'v1.0.1', got '%s'", result.Metadata.Version) + } + if len(result.TarballData) == 0 { + t.Error("Expected non-empty tarball data") + } + }, + }, + { + name: "ruleset not found", + rulesetName: "nonexistent-ruleset", + version: "v1.0.0", + expectError: true, + errorContains: "not found", + }, + { + name: "version not found", + rulesetName: "dca", + version: "v99.99.99", + expectError: true, + errorContains: "not found", + }, + { + name: "empty ruleset name", + rulesetName: "", + version: "v1.0.0", + expectError: true, + errorContains: "not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := usecase.DownloadRuleset(ctx, s, tt.rulesetName, tt.version) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error containing '%s', got nil", tt.errorContains) + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + return + } + if tt.validateResult != nil { + tt.validateResult(t, result) + } + } + }) + } +} + +func TestRulesetDownloadUseCase_resolveVersion(t *testing.T) { + // Initialize logger + err := zlog.NewSugaredDevLogger() + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + defer zlog.SyncZap() + + projectRoot, err := filepath.Abs("../../") + if err != nil { + t.Fatalf("failed to get project root: %v", err) + } + testStoragePath := filepath.Join(projectRoot, "test-support", "rulesets") + + if _, err := os.Stat(testStoragePath); os.IsNotExist(err) { + t.Skipf("Test data not found at %s, skipping tests", testStoragePath) + } + + config := &myconfig.ServerConfig{} + config.Rulesets.StoragePath = testStoragePath + + usecase := NewRulesetDownload(config) + s := zlog.S + + tests := []struct { + name string + rulesetName string + version string + expectError bool + errorContains string + }{ + { + name: "resolve specific version", + rulesetName: "dca", + version: "v1.0.1", + expectError: false, + }, + { + name: "resolve latest symlink", + rulesetName: "dca", + version: "latest", + expectError: false, + }, + { + name: "ruleset does not exist", + rulesetName: "nonexistent", + version: "v1.0.0", + expectError: true, + errorContains: "not found", + }, + { + name: "version does not exist", + rulesetName: "dca", + version: "v99.99.99", + expectError: true, + errorContains: "not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := usecase.resolveVersion(s, tt.rulesetName, tt.version) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + return + } + if result == "" { + t.Error("Expected non-empty result path") + } + // Verify the path exists + if _, err := os.Stat(result); err != nil { + t.Errorf("Resolved path does not exist: %s", result) + } + } + }) + } +} + +func TestRulesetDownloadUseCase_readMetadata(t *testing.T) { + // Initialize logger + err := zlog.NewSugaredDevLogger() + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + defer zlog.SyncZap() + + projectRoot, err := filepath.Abs("../../") + if err != nil { + t.Fatalf("failed to get project root: %v", err) + } + testStoragePath := filepath.Join(projectRoot, "test-support", "rulesets") + + if _, err := os.Stat(testStoragePath); os.IsNotExist(err) { + t.Skipf("Test data not found at %s, skipping tests", testStoragePath) + } + + config := &myconfig.ServerConfig{} + config.Rulesets.StoragePath = testStoragePath + + usecase := NewRulesetDownload(config) + s := zlog.S + + tests := []struct { + name string + versionPath string + expectError bool + errorContains string + validateMeta func(t *testing.T, meta RulesetMetadata) + }{ + { + name: "read valid metadata", + versionPath: filepath.Join(testStoragePath, "dca", "v1.0.1"), + expectError: false, + validateMeta: func(t *testing.T, meta RulesetMetadata) { + if meta.Name != "dca" { + t.Errorf("Expected name 'dca', got '%s'", meta.Name) + } + if meta.Version != "v1.0.1" { + t.Errorf("Expected version 'v1.0.1', got '%s'", meta.Version) + } + if meta.ChecksumSHA256 == "" { + t.Error("Expected non-empty checksum") + } + }, + }, + { + name: "metadata file does not exist", + versionPath: filepath.Join(testStoragePath, "nonexistent"), + expectError: true, + errorContains: "failed to read metadata", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := usecase.readMetadata(s, tt.versionPath) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + return + } + if tt.validateMeta != nil { + tt.validateMeta(t, result) + } + } + }) + } +} + +func TestRulesetDownloadUseCase_readTarball(t *testing.T) { + // Initialize logger + err := zlog.NewSugaredDevLogger() + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + defer zlog.SyncZap() + + projectRoot, err := filepath.Abs("../../") + if err != nil { + t.Fatalf("failed to get project root: %v", err) + } + testStoragePath := filepath.Join(projectRoot, "test-support", "rulesets") + + if _, err := os.Stat(testStoragePath); os.IsNotExist(err) { + t.Skipf("Test data not found at %s, skipping tests", testStoragePath) + } + + config := &myconfig.ServerConfig{} + config.Rulesets.StoragePath = testStoragePath + + usecase := NewRulesetDownload(config) + s := zlog.S + + tests := []struct { + name string + versionPath string + rulesetName string + rulesetVersion string + expectError bool + errorContains string + }{ + { + name: "read valid tarball", + versionPath: filepath.Join(testStoragePath, "dca", "v1.0.1"), + rulesetName: "dca", + rulesetVersion: "v1.0.1", + expectError: false, + }, + { + name: "tarball does not exist", + versionPath: filepath.Join(testStoragePath, "dca", "v1.0.1"), + rulesetName: "dca", + rulesetVersion: "v99.99.99", + expectError: true, + errorContains: "not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := usecase.readTarball(s, tt.versionPath, tt.rulesetName, tt.rulesetVersion) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + return + } + if len(result) == 0 { + t.Error("Expected non-empty tarball data") + } + } + }) + } +} + +func TestRulesetMetadata_JSON(t *testing.T) { + // Test JSON marshaling/unmarshaling + original := RulesetMetadata{ + Name: "test-ruleset", + Version: "v1.0.0", + Description: "Test description", + CreatedAt: "2025-01-01T00:00:00Z", + ChecksumSHA256: "abc123", + } + + // Marshal to JSON + jsonData, err := json.Marshal(original) + if err != nil { + t.Fatalf("Failed to marshal metadata: %v", err) + } + + // Unmarshal back + var decoded RulesetMetadata + err = json.Unmarshal(jsonData, &decoded) + if err != nil { + t.Fatalf("Failed to unmarshal metadata: %v", err) + } + + // Verify fields + if decoded.Name != original.Name { + t.Errorf("Name mismatch: expected '%s', got '%s'", original.Name, decoded.Name) + } + if decoded.Version != original.Version { + t.Errorf("Version mismatch: expected '%s', got '%s'", original.Version, decoded.Version) + } + if decoded.Description != original.Description { + t.Errorf("Description mismatch: expected '%s', got '%s'", original.Description, decoded.Description) + } + if decoded.CreatedAt != original.CreatedAt { + t.Errorf("CreatedAt mismatch: expected '%s', got '%s'", original.CreatedAt, decoded.CreatedAt) + } + if decoded.ChecksumSHA256 != original.ChecksumSHA256 { + t.Errorf("ChecksumSHA256 mismatch: expected '%s', got '%s'", original.ChecksumSHA256, decoded.ChecksumSHA256) + } +} diff --git a/test-support/rulesets/dca/latest b/test-support/rulesets/dca/latest new file mode 120000 index 0000000..6a2b0ac --- /dev/null +++ b/test-support/rulesets/dca/latest @@ -0,0 +1 @@ +v1.0.1 \ No newline at end of file diff --git a/test-support/rulesets/dca/v1.0.0/dca-v1.0.0.tar.gz b/test-support/rulesets/dca/v1.0.0/dca-v1.0.0.tar.gz new file mode 100644 index 0000000..a0351c1 Binary files /dev/null and b/test-support/rulesets/dca/v1.0.0/dca-v1.0.0.tar.gz differ diff --git a/test-support/rulesets/dca/v1.0.0/manifest.json b/test-support/rulesets/dca/v1.0.0/manifest.json new file mode 100644 index 0000000..e4d88f2 --- /dev/null +++ b/test-support/rulesets/dca/v1.0.0/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "dca", + "version": "v1.0.0", + "description": "Deep Code Analysis cryptography detection rules (test version)", + "created_at": "2025-01-01T10:00:00Z", + "checksum_sha256": "7e8ae2b648e05924b1cd3627546207867365f0e45bfffe39223981e9b4951db0" +} diff --git a/test-support/rulesets/dca/v1.0.1/dca-v1.0.1.tar.gz b/test-support/rulesets/dca/v1.0.1/dca-v1.0.1.tar.gz new file mode 100644 index 0000000..a0351c1 Binary files /dev/null and b/test-support/rulesets/dca/v1.0.1/dca-v1.0.1.tar.gz differ diff --git a/test-support/rulesets/dca/v1.0.1/manifest.json b/test-support/rulesets/dca/v1.0.1/manifest.json new file mode 100644 index 0000000..aaaba59 --- /dev/null +++ b/test-support/rulesets/dca/v1.0.1/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "dca", + "version": "v1.0.1", + "description": "Deep Code Analysis cryptography detection rules (test version)", + "created_at": "2025-01-01T11:00:00Z", + "checksum_sha256": "7e8ae2b648e05924b1cd3627546207867365f0e45bfffe39223981e9b4951db0" +}