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
81 changes: 79 additions & 2 deletions detect/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,13 +646,65 @@ func (e *Engine) safeReadFile(file string) ([]byte, error) {
return io.ReadAll(f)
}

// contains checks if a file contains any of the given strings.
// contains checks if an exact file or any regular file matching a glob contains
// one of the given strings.
func (e *Engine) contains(file string, patterns []string) bool {
if kb.HasGlobPattern(file) {
return e.globContains(file, patterns)
}

data, err := e.safeReadFile(file)
if err != nil {
return false
}
content := string(data)
return containsAny(string(data), patterns)
}

func (e *Engine) globContains(pattern string, contentPatterns []string) bool {
found := false
errFound := errors.New("found")
_ = filepath.WalkDir(e.Root, func(filePath string, d os.DirEntry, err error) error {
if err != nil {
return nil
}

rel, err := filepath.Rel(e.Root, filePath)
if err != nil {
return nil
}
if d.IsDir() {
if rel != "." && e.shouldSkipDir(d.Name()) {
return filepath.SkipDir
}
if e.ScanDepth > 0 && rel != "." &&
strings.Count(rel, string(filepath.Separator))+1 > e.ScanDepth {
return filepath.SkipDir
}
if !e.isTracked(rel) {
return filepath.SkipDir
}
return nil
}

if !e.isTracked(rel) || !matchPathPattern(pattern, filepath.ToSlash(rel)) {
return nil
}
info, err := d.Info()
if err != nil || !info.Mode().IsRegular() {
return nil
}

data, err := e.safeReadFile(rel)
if err != nil || !containsAny(string(data), contentPatterns) {
return nil
}
found = true
return errFound
})
return found
}

func containsAny(content string, patterns []string) bool {
for _, p := range patterns {
if strings.Contains(content, p) {
return true
Expand All @@ -661,6 +713,31 @@ func (e *Engine) contains(file string, patterns []string) bool {
return false
}

// matchPathPattern matches slash-separated paths and treats ** as zero or more
// complete path segments.
func matchPathPattern(pattern, name string) bool {
pattern = filepath.ToSlash(pattern)
name = filepath.ToSlash(name)
return matchPathSegments(strings.Split(pattern, "/"), strings.Split(name, "/"))
}

func matchPathSegments(pattern, name []string) bool {
if len(pattern) == 0 {
return len(name) == 0
}
if pattern[0] == "**" {
if matchPathSegments(pattern[1:], name) {
return true
}
return len(name) > 0 && matchPathSegments(pattern, name[1:])
}
if len(name) == 0 {
return false
}
matched, err := path.Match(pattern[0], name[0])
return err == nil && matched && matchPathSegments(pattern[1:], name[1:])
}

// loadDeps parses all manifest files in the project using the manifests library
// and populates the dependency caches. Called lazily on first dependency check.
func (e *Engine) loadDeps() {
Expand Down
77 changes: 77 additions & 0 deletions detect/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,83 @@ func TestGlobIgnoresDirectories(t *testing.T) {
}
}

func TestFileContainsGlob(t *testing.T) {
const marker = "native extension marker"

t.Run("root file", func(t *testing.T) {
dir := t.TempDir()
writeProjectFile(t, dir, "example.gemspec", marker)

if !New(loadKB(t), dir).contains("**/*.gemspec", []string{marker}) {
t.Error("expected **/*.gemspec to inspect a root file")
}
})

t.Run("nested file", func(t *testing.T) {
dir := t.TempDir()
writeProjectFile(t, dir, "lib/example.ex", marker)

if !New(loadKB(t), dir).contains("**/*.ex", []string{marker}) {
t.Error("expected **/*.ex to inspect a nested file")
}
})

t.Run("multiple matches", func(t *testing.T) {
dir := t.TempDir()
writeProjectFile(t, dir, "src/a.rs", "near miss")
writeProjectFile(t, dir, "src/b.rs", marker)

engine := New(loadKB(t), dir)
tool := &kb.ToolDef{
Detect: kb.DetectInfo{
FileContains: map[string][]string{"**/*.rs": {marker}},
},
}
if got := engine.matchTool(tool); got != brief.ConfidenceHigh {
t.Errorf("glob-backed file_contains confidence = %q, want %q", got, brief.ConfidenceHigh)
}
})

t.Run("skipped directory", func(t *testing.T) {
dir := t.TempDir()
writeProjectFile(t, dir, "vendor/native.rs", marker)

if New(loadKB(t), dir).contains("**/*.rs", []string{marker}) {
t.Error("file_contains glob should not inspect skipped directories")
}
})

t.Run("regular files only", func(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "native.rs"), 0o755); err != nil {
t.Fatal(err)
}

if New(loadKB(t), dir).contains("**/*.rs", []string{marker}) {
t.Error("file_contains glob should not inspect directories")
}
})

t.Run("near miss", func(t *testing.T) {
dir := t.TempDir()
writeProjectFile(t, dir, "src/native.rs", "no matching content")
writeProjectFile(t, dir, "notes.txt", marker)

if New(loadKB(t), dir).contains("**/*.rs", []string{marker}) {
t.Error("content in a non-matching file should not satisfy file_contains")
}
})

t.Run("exact path", func(t *testing.T) {
dir := t.TempDir()
writeProjectFile(t, dir, "config/tool.toml", marker)

if !New(loadKB(t), dir).contains("config/tool.toml", []string{marker}) {
t.Error("exact file_contains path should retain its existing behavior")
}
})
}

func TestDirectoryGlobPattern(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "Foo.xcodeproj"), 0o755); err != nil {
Expand Down
11 changes: 9 additions & 2 deletions detect/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,10 +314,17 @@ func matchesDetectionPatterns(tool *kb.ToolDef, changed map[string]bool, changed
}

func matchesContentTargets(tool *kb.ToolDef, changed map[string]bool) bool {
for file := range tool.Detect.FileContains {
if changed[file] {
for pattern := range tool.Detect.FileContains {
if changed[pattern] {
return true
}
if kb.HasGlobPattern(pattern) {
for file := range changed {
if matchPathPattern(pattern, file) {
return true
}
}
}
}
for file := range tool.Detect.KeyExists {
if changed[file] {
Expand Down
32 changes: 32 additions & 0 deletions detect/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/git-pkgs/brief"
"github.com/git-pkgs/brief/kb"
)

func TestFilterResources(t *testing.T) {
Expand Down Expand Up @@ -163,6 +164,37 @@ func TestFilterByChangedFiles_Tools(t *testing.T) {
}
}

func TestToolMatchesChangedFiles_FileContainsGlob(t *testing.T) {
tool := &kb.ToolDef{
Detect: kb.DetectInfo{
FileContains: map[string][]string{
"**/*.rs": {"native extension marker"},
"config/tool.toml": {"enabled = true"},
},
},
}

tests := []struct {
name string
changed string
want bool
}{
{name: "root glob match", changed: "lib.rs", want: true},
{name: "nested glob match", changed: "native/example/src/lib.rs", want: true},
{name: "exact path", changed: "config/tool.toml", want: true},
{name: "near miss", changed: "native/example/src/lib.go", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
changed := map[string]bool{tt.changed: true}
if got := toolMatchesChangedFiles(tool, changed, nil); got != tt.want {
t.Errorf("toolMatchesChangedFiles(%q) = %v, want %v", tt.changed, got, tt.want)
}
})
}
}

func TestFilterByChangedFiles_PackageManagers(t *testing.T) {
knowledgeBase := loadKB(t)

Expand Down