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"
+}