Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/analyzers/govulncheck/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ func (a Analyzer) cache() *resultCache {
if a.DisableCache {
return nil
}
return newResultCache(a.CacheDir, a.CacheTTL)
return newResultCache(a.CacheDir, a.CacheTTL, a.logger())
}

func (a Analyzer) logger() *zap.Logger { return ensureLogger(a.Logger) }
Expand Down
36 changes: 23 additions & 13 deletions components/analyzers/govulncheck/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

cachepkg "github.com/bomly-dev/bomly-sdk/filecache"
"go.uber.org/zap"

"github.com/bomly-dev/bomly-sdk/system"
)
Expand Down Expand Up @@ -47,35 +48,44 @@ type cachedRunnerResult struct {

// newResultCache constructs a result cache rooted at dir. If dir is
// empty, the OS user cache directory is used. Errors creating the cache
// directory are non-fatal — they return a nil resultCache that the caller
// can use without checks.
func newResultCache(dir string, ttl time.Duration) *resultCache {
// directory are non-fatal — they log one WARN and return a nil
// resultCache that the caller can use without checks.
func newResultCache(dir string, ttl time.Duration, logger *zap.Logger) *resultCache {
logger = ensureLogger(logger)
if ttl <= 0 {
ttl = defaultCacheTTL
}
root := dir
if root == "" {
root = defaultCacheRoot()
}
if root == "" {
return nil
defaultRoot, err := defaultCacheRoot()
if err != nil {
logger.Warn("govulncheck: result cache disabled: user cache directory unavailable (non-fatal)",
zap.Error(err))
return nil
}
root = defaultRoot
}
store, err := cachepkg.NewFileCache(root, ttl)
if err != nil {
logger.Warn("govulncheck: result cache disabled: cache initialization failed (non-fatal)",
zap.String("dir", root), zap.Error(err))
return nil
}
return &resultCache{store: store}
}

// defaultCacheRoot returns the platform-appropriate cache directory for
// govulncheck analyzer results, or "" if the user cache directory cannot
// be determined.
func defaultCacheRoot() string {
// govulncheck analyzer results, or an error when the user cache
// directory cannot be determined.
func defaultCacheRoot() (string, error) {
base, err := os.UserCacheDir()
if err != nil || base == "" {
return ""
if err != nil {
return "", err
}
if base == "" {
return "", errors.New("user cache directory is empty")
}
return filepath.Join(base, "bomly", "analyzers", "govulncheck")
return filepath.Join(base, "bomly", "analyzers", "govulncheck"), nil
}

// keyFor builds a stable cache key for one module run. The key folds
Expand Down
23 changes: 20 additions & 3 deletions components/analyzers/govulncheck/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import (
"testing"

model "github.com/bomly-dev/bomly-sdk"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)

func TestResultCacheRoundTrip(t *testing.T) {
dir := t.TempDir()
cache := newResultCache(dir, 0)
cache := newResultCache(dir, 0, nil)
if cache == nil {
t.Fatal("newResultCache returned nil for a writable dir")
}
Expand Down Expand Up @@ -40,7 +42,7 @@ func TestResultCacheRoundTrip(t *testing.T) {

func TestResultCacheIsolatesByRunnerName(t *testing.T) {
dir := t.TempDir()
cache := newResultCache(dir, 0)
cache := newResultCache(dir, 0, nil)
moduleDir := newGoModuleDir(t)

if err := cache.set(moduleDir, "builtin", RunnerResult{Findings: map[string]Finding{"A": {OSV: "A"}}}); err != nil {
Expand All @@ -53,7 +55,7 @@ func TestResultCacheIsolatesByRunnerName(t *testing.T) {

func TestResultCacheInvalidatesOnGoSumChange(t *testing.T) {
dir := t.TempDir()
cache := newResultCache(dir, 0)
cache := newResultCache(dir, 0, nil)
moduleDir := newGoModuleDir(t)

// Seed go.sum so checksum is stable across writes.
Expand Down Expand Up @@ -145,3 +147,18 @@ func TestAnalyzerDisableCacheAlwaysRunsRunner(t *testing.T) {
t.Errorf("DisableCache should re-run runner per call; got %d calls", runner.called)
}
}

func TestNewResultCacheWarnsWhenInitFails(t *testing.T) {
core, logs := observer.New(zap.WarnLevel)
blocker := filepath.Join(t.TempDir(), "blocker")
if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("write blocker file: %v", err)
}
cache := newResultCache(filepath.Join(blocker, "nested"), 0, zap.New(core))
if cache != nil {
t.Fatal("expected nil cache when the cache root cannot be created")
}
if got := logs.FilterLevelExact(zap.WarnLevel).Len(); got != 1 {
t.Fatalf("expected exactly one WARN log, got %d: %v", got, logs.All())
}
}
2 changes: 1 addition & 1 deletion components/analyzers/jsreach/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ func (a Analyzer) cache() *resultCache {
if a.DisableCache {
return nil
}
return newResultCache(a.CacheDir, a.CacheTTL)
return newResultCache(a.CacheDir, a.CacheTTL, a.logger())
}

func resultFromRequest(req model.AnalyzeRequest) model.AnalyzeResult {
Expand Down
36 changes: 23 additions & 13 deletions components/analyzers/jsreach/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

cachepkg "github.com/bomly-dev/bomly-sdk/filecache"
"go.uber.org/zap"

"github.com/bomly-dev/bomly-sdk/system"
)
Expand Down Expand Up @@ -48,35 +49,44 @@ type cachedRunnerResult struct {

// newResultCache constructs a result cache rooted at dir. If dir is
// empty, the OS user cache directory is used. Errors creating the
// cache directory are non-fatal — they return a nil resultCache that
// the caller can use without checks.
func newResultCache(dir string, ttl time.Duration) *resultCache {
// cache directory are non-fatal — they log one WARN and return a nil
// resultCache that the caller can use without checks.
func newResultCache(dir string, ttl time.Duration, logger *zap.Logger) *resultCache {
logger = ensureLogger(logger)
if ttl <= 0 {
ttl = defaultCacheTTL
}
root := dir
if root == "" {
root = defaultCacheRoot()
}
if root == "" {
return nil
defaultRoot, err := defaultCacheRoot()
if err != nil {
logger.Warn("jsreach: result cache disabled: user cache directory unavailable (non-fatal)",
zap.Error(err))
return nil
}
root = defaultRoot
}
store, err := cachepkg.NewFileCache(root, ttl)
if err != nil {
logger.Warn("jsreach: result cache disabled: cache initialization failed (non-fatal)",
zap.String("dir", root), zap.Error(err))
return nil
}
return &resultCache{store: store}
}

// defaultCacheRoot returns the platform-appropriate cache directory
// for jsreach analyzer results, or "" if the user cache directory
// cannot be determined.
func defaultCacheRoot() string {
// for jsreach analyzer results, or an error when the user cache
// directory cannot be determined.
func defaultCacheRoot() (string, error) {
base, err := os.UserCacheDir()
if err != nil || base == "" {
return ""
if err != nil {
return "", err
}
if base == "" {
return "", errors.New("user cache directory is empty")
}
return filepath.Join(base, "bomly", "analyzers", "jsreach")
return filepath.Join(base, "bomly", "analyzers", "jsreach"), nil
}

// keyFor builds a stable cache key for one project pass. Folds every
Expand Down
25 changes: 21 additions & 4 deletions components/analyzers/jsreach/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import (
"testing"

model "github.com/bomly-dev/bomly-sdk"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)

func TestResultCacheRoundTrip(t *testing.T) {
dir := t.TempDir()
cache := newResultCache(dir, 0)
cache := newResultCache(dir, 0, nil)
if cache == nil {
t.Fatal("newResultCache returned nil for a writable dir")
}
Expand Down Expand Up @@ -42,7 +44,7 @@ func TestResultCacheRoundTrip(t *testing.T) {

func TestResultCacheIsolatesByRunnerName(t *testing.T) {
dir := t.TempDir()
cache := newResultCache(dir, 0)
cache := newResultCache(dir, 0, nil)
projectDir := newNPMProjectDir(t)

if err := cache.set(projectDir, "builtin", "1.0", RunnerResult{ImportedPackages: map[string]struct{}{"a": {}}}); err != nil {
Expand All @@ -55,7 +57,7 @@ func TestResultCacheIsolatesByRunnerName(t *testing.T) {

func TestResultCacheIsolatesByRunnerVersion(t *testing.T) {
dir := t.TempDir()
cache := newResultCache(dir, 0)
cache := newResultCache(dir, 0, nil)
projectDir := newNPMProjectDir(t)

if err := cache.set(projectDir, "builtin", "1.0", RunnerResult{ImportedPackages: map[string]struct{}{"a": {}}}); err != nil {
Expand All @@ -68,7 +70,7 @@ func TestResultCacheIsolatesByRunnerVersion(t *testing.T) {

func TestResultCacheInvalidatesOnLockfileChange(t *testing.T) {
dir := t.TempDir()
cache := newResultCache(dir, 0)
cache := newResultCache(dir, 0, nil)
projectDir := newNPMProjectDir(t)

// Seed package-lock.json so checksum is stable across writes.
Expand Down Expand Up @@ -159,3 +161,18 @@ func TestAnalyzerDisableCacheAlwaysRunsRunner(t *testing.T) {
t.Errorf("DisableCache should re-run runner per call; got %d calls", runner.called)
}
}

func TestNewResultCacheWarnsWhenInitFails(t *testing.T) {
core, logs := observer.New(zap.WarnLevel)
blocker := filepath.Join(t.TempDir(), "blocker")
if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("write blocker file: %v", err)
}
cache := newResultCache(filepath.Join(blocker, "nested"), 0, zap.New(core))
if cache != nil {
t.Fatal("expected nil cache when the cache root cannot be created")
}
if got := logs.FilterLevelExact(zap.WarnLevel).Len(); got != 1 {
t.Fatalf("expected exactly one WARN log, got %d: %v", got, logs.All())
}
}
26 changes: 21 additions & 5 deletions components/analyzers/jsreach/entrypoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"

"github.com/bomly-dev/bomly-sdk/system"
)
Expand Down Expand Up @@ -148,9 +149,17 @@ func binEntryStrings(raw json.RawMessage) []string {
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err == nil {
out := make([]string, 0, len(m))
for _, value := range m {
if value != "" {
names := make([]string, 0, len(m))
for name := range m {
names = append(names, name)
}
// Emit in sorted key order so the entry list (and everything
// derived from it — logs, cache keys, fuzz determinism) is
// stable across runs.
sort.Strings(names)
out := make([]string, 0, len(names))
for _, name := range names {
if value := m[name]; value != "" {
out = append(out, value)
}
}
Expand All @@ -175,8 +184,15 @@ func walkJSONStrings(raw json.RawMessage, emit func(string)) {
}
var asObject map[string]json.RawMessage
if err := json.Unmarshal(raw, &asObject); err == nil {
for _, child := range asObject {
walkJSONStrings(child, emit)
// Walk object members in sorted key order so emission order is
// deterministic (Go map iteration is randomized).
keys := make([]string, 0, len(asObject))
for key := range asObject {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
walkJSONStrings(asObject[key], emit)
}
}
}
Expand Down
50 changes: 50 additions & 0 deletions components/analyzers/jsreach/entrypoints_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package jsreach

import (
"encoding/json"
"reflect"
"testing"

testutil "github.com/bomly-dev/bomly-sdk/testkit"
)

// FuzzEntryPointStrings verifies that the package.json entry-point
// helpers never panic and produce deterministic output for arbitrary
// (valid, malformed, or truncated) JSON input within the shared fuzz
// input bound. The helpers tolerate any shape by design, so every
// input is expected to succeed; determinism is the real contract —
// walkJSONStrings and binEntryStrings walk JSON objects, and emission
// order must not depend on Go's randomized map iteration.
func FuzzEntryPointStrings(f *testing.F) {
for _, seed := range []string{
`"./index.js"`,
`{"my-cli": "./cli.js", "other": "./other.js"}`,
`{".": {"import": "./esm/index.js", "require": "./cjs/index.js"}, "./util": "./util.js"}`,
`["./a.js", {"b": "./b.js"}, ["./c.js"]]`,
`{"browser": {"./fs": false}}`,
`{"unterminated": "./x.js"`,
`null`,
`42`,
``,
} {
f.Add([]byte(seed))
}
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > testutil.MaxFuzzInputSize {
return
}
raw := json.RawMessage(data)
helpers := map[string]func(json.RawMessage) []string{
"browserEntryStrings": browserEntryStrings,
"exportsEntryStrings": exportsEntryStrings,
"binEntryStrings": binEntryStrings,
}
for name, helper := range helpers {
first := helper(raw)
second := helper(raw)
if !reflect.DeepEqual(first, second) {
t.Fatalf("%s changed result for identical input: first=%v second=%v", name, first, second)
}
}
})
}
Loading
Loading