mirror of
https://github.com/imjasonh/snoop
synced 2026-07-18 06:35:10 +00:00
Add processor package with path normalization and deduplication
This commit is contained in:
parent
23d94e3049
commit
d8c904c3da
7 changed files with 850 additions and 2 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,5 +1,5 @@
|
|||
# Binaries
|
||||
snoop
|
||||
^snoop
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
|
|
|||
86
cmd/snoop/main.go
Normal file
86
cmd/snoop/main.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/imjasonh/snoop/pkg/cgroup"
|
||||
"github.com/imjasonh/snoop/pkg/ebpf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var cgroupPath string
|
||||
flag.StringVar(&cgroupPath, "cgroup", "", "Cgroup path to trace (e.g., /system.slice/docker-abc123.scope)")
|
||||
flag.Parse()
|
||||
|
||||
if err := run(cgroupPath); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(cgroupPath string) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Handle signals for graceful shutdown
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Println("Received signal, shutting down...")
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Create and load the eBPF probe
|
||||
log.Println("Loading eBPF program...")
|
||||
probe, err := ebpf.NewProbe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating probe: %w", err)
|
||||
}
|
||||
defer probe.Close()
|
||||
|
||||
log.Println("eBPF program loaded successfully")
|
||||
|
||||
// Add cgroup to trace
|
||||
if cgroupPath != "" {
|
||||
cgroupID, err := cgroup.GetCgroupIDByPath(cgroupPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting cgroup ID: %w", err)
|
||||
}
|
||||
log.Printf("Tracing cgroup: %s (ID: %d)", cgroupPath, cgroupID)
|
||||
if err := probe.AddTracedCgroup(cgroupID); err != nil {
|
||||
return fmt.Errorf("adding traced cgroup: %w", err)
|
||||
}
|
||||
} else {
|
||||
log.Println("No cgroup specified. Use -cgroup flag to specify a cgroup to trace.")
|
||||
log.Println("Example: -cgroup /system.slice/docker-abc123.scope")
|
||||
return fmt.Errorf("no cgroup specified")
|
||||
}
|
||||
|
||||
// Read and print events
|
||||
log.Println("Waiting for events (press Ctrl+C to exit)...")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
event, err := probe.ReadEvent(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
log.Printf("Error reading event: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[PID %d] [Cgroup %d] [Syscall %d] %s\n",
|
||||
event.PID, event.CgroupID, event.SyscallNr, event.Path)
|
||||
}
|
||||
}
|
||||
93
pkg/processor/normalize.go
Normal file
93
pkg/processor/normalize.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizePath normalizes a file path by:
|
||||
// - Resolving . and .. components
|
||||
// - Converting relative paths to absolute using the provided working directory
|
||||
// - Preserving symlinks (not following them)
|
||||
//
|
||||
// The pid parameter is used to look up the process's working directory
|
||||
// via /proc/<pid>/cwd when the path is relative and cwd is empty.
|
||||
func NormalizePath(path string, pid uint32, cwd string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle absolute paths: just clean them
|
||||
if filepath.IsAbs(path) {
|
||||
return cleanPath(path)
|
||||
}
|
||||
|
||||
// For relative paths, we need a working directory
|
||||
workDir := cwd
|
||||
if workDir == "" && pid > 0 {
|
||||
// Try to read the process's cwd from /proc
|
||||
workDir = getProcessCwd(pid)
|
||||
}
|
||||
if workDir == "" {
|
||||
// Fallback: return cleaned relative path prefixed with /
|
||||
// This is a best-effort when we can't determine the cwd
|
||||
return "/" + cleanPath(path)
|
||||
}
|
||||
|
||||
// Join with working directory and clean
|
||||
return cleanPath(filepath.Join(workDir, path))
|
||||
}
|
||||
|
||||
// cleanPath removes . and .. components without following symlinks.
|
||||
// Unlike filepath.Clean, this preserves trailing slashes and handles
|
||||
// edge cases more carefully for our use case.
|
||||
func cleanPath(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// filepath.Clean handles ., .., and multiple slashes
|
||||
cleaned := filepath.Clean(path)
|
||||
|
||||
// Ensure absolute paths start with /
|
||||
if !strings.HasPrefix(cleaned, "/") && strings.HasPrefix(path, "/") {
|
||||
cleaned = "/" + cleaned
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// getProcessCwd reads the working directory of a process from /proc.
|
||||
// Returns empty string if the process doesn't exist or cwd can't be read.
|
||||
func getProcessCwd(pid uint32) string {
|
||||
// Read the symlink target of /proc/<pid>/cwd
|
||||
cwdPath := fmt.Sprintf("/proc/%d/cwd", pid)
|
||||
cwd, err := os.Readlink(cwdPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
// IsExcluded checks if a path should be excluded based on the provided prefixes.
|
||||
// Paths starting with any prefix in the exclusion list are excluded.
|
||||
func IsExcluded(path string, excludePrefixes []string) bool {
|
||||
for _, prefix := range excludePrefixes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultExclusions returns the default path prefixes to exclude from tracing.
|
||||
// These are system paths that are not relevant for image slimming.
|
||||
func DefaultExclusions() []string {
|
||||
return []string{
|
||||
"/proc/",
|
||||
"/sys/",
|
||||
"/dev/",
|
||||
}
|
||||
}
|
||||
239
pkg/processor/normalize_test.go
Normal file
239
pkg/processor/normalize_test.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePath(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
path string
|
||||
pid uint32
|
||||
cwd string
|
||||
want string
|
||||
}{{
|
||||
desc: "empty path",
|
||||
path: "",
|
||||
want: "",
|
||||
}, {
|
||||
desc: "absolute path unchanged",
|
||||
path: "/etc/passwd",
|
||||
want: "/etc/passwd",
|
||||
}, {
|
||||
desc: "absolute path with single dot",
|
||||
path: "/etc/./passwd",
|
||||
want: "/etc/passwd",
|
||||
}, {
|
||||
desc: "absolute path with double dots",
|
||||
path: "/etc/nginx/../passwd",
|
||||
want: "/etc/passwd",
|
||||
}, {
|
||||
desc: "absolute path with multiple dots",
|
||||
path: "/usr/local/./bin/../lib/./test",
|
||||
want: "/usr/local/lib/test",
|
||||
}, {
|
||||
desc: "absolute path with leading double dots",
|
||||
path: "/../etc/passwd",
|
||||
want: "/etc/passwd",
|
||||
}, {
|
||||
desc: "absolute path with multiple slashes",
|
||||
path: "/etc//nginx///conf.d////default.conf",
|
||||
want: "/etc/nginx/conf.d/default.conf",
|
||||
}, {
|
||||
desc: "relative path with cwd",
|
||||
path: "config/app.yaml",
|
||||
cwd: "/home/user/myapp",
|
||||
want: "/home/user/myapp/config/app.yaml",
|
||||
}, {
|
||||
desc: "relative path with dots and cwd",
|
||||
path: "./config/../data/file.txt",
|
||||
cwd: "/app",
|
||||
want: "/app/data/file.txt",
|
||||
}, {
|
||||
desc: "relative path with parent traversal",
|
||||
path: "../shared/lib.so",
|
||||
cwd: "/app/bin",
|
||||
want: "/app/shared/lib.so",
|
||||
}, {
|
||||
desc: "relative path without cwd or pid falls back",
|
||||
path: "some/file.txt",
|
||||
want: "/some/file.txt",
|
||||
}, {
|
||||
desc: "root path",
|
||||
path: "/",
|
||||
want: "/",
|
||||
}, {
|
||||
desc: "dot only path with cwd",
|
||||
path: ".",
|
||||
cwd: "/home/user",
|
||||
want: "/home/user",
|
||||
}, {
|
||||
desc: "double dot only path with cwd",
|
||||
path: "..",
|
||||
cwd: "/home/user",
|
||||
want: "/home",
|
||||
}, {
|
||||
desc: "complex traversal",
|
||||
path: "/a/b/c/../../d/./e/../f",
|
||||
want: "/a/d/f",
|
||||
}, {
|
||||
desc: "double dots past root",
|
||||
path: "/a/../../b",
|
||||
want: "/b",
|
||||
}} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got := NormalizePath(tt.path, tt.pid, tt.cwd)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizePath(%q, %d, %q) = %q, want %q",
|
||||
tt.path, tt.pid, tt.cwd, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePathWithPid(t *testing.T) {
|
||||
// This test uses the current process's cwd via /proc
|
||||
// Skip if /proc is not available
|
||||
if _, err := os.Stat("/proc/self/cwd"); err != nil {
|
||||
t.Skip("skipping: /proc not available")
|
||||
}
|
||||
|
||||
currentCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current working directory: %v", err)
|
||||
}
|
||||
|
||||
pid := uint32(os.Getpid())
|
||||
got := NormalizePath("relative/path", pid, "")
|
||||
|
||||
want := currentCwd + "/relative/path"
|
||||
if got != want {
|
||||
t.Errorf("NormalizePath with pid lookup = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPath(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
path string
|
||||
want string
|
||||
}{{
|
||||
desc: "empty path",
|
||||
path: "",
|
||||
want: "",
|
||||
}, {
|
||||
desc: "simple path",
|
||||
path: "/usr/bin/test",
|
||||
want: "/usr/bin/test",
|
||||
}, {
|
||||
desc: "single dot",
|
||||
path: "/usr/./bin",
|
||||
want: "/usr/bin",
|
||||
}, {
|
||||
desc: "double dot",
|
||||
path: "/usr/local/../bin",
|
||||
want: "/usr/bin",
|
||||
}, {
|
||||
desc: "multiple slashes",
|
||||
path: "/usr///bin//test",
|
||||
want: "/usr/bin/test",
|
||||
}, {
|
||||
desc: "trailing dot",
|
||||
path: "/usr/bin/.",
|
||||
want: "/usr/bin",
|
||||
}, {
|
||||
desc: "only dots",
|
||||
path: "/./././",
|
||||
want: "/",
|
||||
}} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got := cleanPath(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("cleanPath(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExcluded(t *testing.T) {
|
||||
exclusions := []string{"/proc/", "/sys/", "/dev/"}
|
||||
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
path string
|
||||
want bool
|
||||
}{{
|
||||
desc: "proc path excluded",
|
||||
path: "/proc/self/status",
|
||||
want: true,
|
||||
}, {
|
||||
desc: "sys path excluded",
|
||||
path: "/sys/kernel/debug/tracing",
|
||||
want: true,
|
||||
}, {
|
||||
desc: "dev path excluded",
|
||||
path: "/dev/null",
|
||||
want: true,
|
||||
}, {
|
||||
desc: "etc path not excluded",
|
||||
path: "/etc/passwd",
|
||||
want: false,
|
||||
}, {
|
||||
desc: "usr path not excluded",
|
||||
path: "/usr/bin/bash",
|
||||
want: false,
|
||||
}, {
|
||||
desc: "empty path not excluded",
|
||||
path: "",
|
||||
want: false,
|
||||
}, {
|
||||
desc: "proc-like path not excluded",
|
||||
path: "/var/proc/data",
|
||||
want: false,
|
||||
}, {
|
||||
desc: "exact proc prefix",
|
||||
path: "/proc/",
|
||||
want: true,
|
||||
}} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got := IsExcluded(tt.path, exclusions)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsExcluded(%q, %v) = %v, want %v",
|
||||
tt.path, exclusions, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExcludedEmptyList(t *testing.T) {
|
||||
// With no exclusions, nothing should be excluded
|
||||
if IsExcluded("/proc/self/status", nil) {
|
||||
t.Error("IsExcluded with nil exclusions should return false")
|
||||
}
|
||||
if IsExcluded("/proc/self/status", []string{}) {
|
||||
t.Error("IsExcluded with empty exclusions should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultExclusions(t *testing.T) {
|
||||
exclusions := DefaultExclusions()
|
||||
|
||||
// Verify we have the expected defaults
|
||||
expected := map[string]bool{
|
||||
"/proc/": true,
|
||||
"/sys/": true,
|
||||
"/dev/": true,
|
||||
}
|
||||
|
||||
if len(exclusions) != len(expected) {
|
||||
t.Errorf("DefaultExclusions() returned %d items, want %d",
|
||||
len(exclusions), len(expected))
|
||||
}
|
||||
|
||||
for _, ex := range exclusions {
|
||||
if !expected[ex] {
|
||||
t.Errorf("unexpected exclusion: %q", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
159
pkg/processor/processor.go
Normal file
159
pkg/processor/processor.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Event represents a file access event from the eBPF program.
|
||||
// This mirrors the ebpf.Event type to avoid circular dependencies.
|
||||
type Event struct {
|
||||
CgroupID uint64
|
||||
PID uint32
|
||||
SyscallNr uint32
|
||||
Path string
|
||||
}
|
||||
|
||||
// Processor handles event processing including path normalization,
|
||||
// exclusion filtering, and deduplication.
|
||||
type Processor struct {
|
||||
seen map[string]struct{}
|
||||
seenMu sync.RWMutex
|
||||
excluded []string
|
||||
|
||||
// Metrics
|
||||
eventsReceived uint64
|
||||
eventsProcessed uint64
|
||||
eventsExcluded uint64
|
||||
eventsDuplicate uint64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewProcessor creates a new event processor with the given exclusion prefixes.
|
||||
// If excludePrefixes is nil, DefaultExclusions() will be used.
|
||||
func NewProcessor(excludePrefixes []string) *Processor {
|
||||
if excludePrefixes == nil {
|
||||
excludePrefixes = DefaultExclusions()
|
||||
}
|
||||
return &Processor{
|
||||
seen: make(map[string]struct{}),
|
||||
excluded: excludePrefixes,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessResult indicates what happened when processing an event.
|
||||
type ProcessResult int
|
||||
|
||||
const (
|
||||
// ResultNew indicates a new unique file was recorded.
|
||||
ResultNew ProcessResult = iota
|
||||
// ResultDuplicate indicates the file was already seen.
|
||||
ResultDuplicate
|
||||
// ResultExcluded indicates the file was filtered by exclusion rules.
|
||||
ResultExcluded
|
||||
// ResultEmpty indicates the path was empty after normalization.
|
||||
ResultEmpty
|
||||
)
|
||||
|
||||
// Process handles an incoming event, normalizing the path and deduplicating.
|
||||
// Returns the normalized path and a result indicating what happened.
|
||||
func (p *Processor) Process(event *Event) (string, ProcessResult) {
|
||||
p.mu.Lock()
|
||||
p.eventsReceived++
|
||||
p.mu.Unlock()
|
||||
|
||||
// Normalize the path
|
||||
normalized := NormalizePath(event.Path, event.PID, "")
|
||||
if normalized == "" {
|
||||
return "", ResultEmpty
|
||||
}
|
||||
|
||||
// Check exclusions
|
||||
if IsExcluded(normalized, p.excluded) {
|
||||
p.mu.Lock()
|
||||
p.eventsExcluded++
|
||||
p.mu.Unlock()
|
||||
return normalized, ResultExcluded
|
||||
}
|
||||
|
||||
// Check for duplicates and add if new
|
||||
p.seenMu.Lock()
|
||||
_, exists := p.seen[normalized]
|
||||
if !exists {
|
||||
p.seen[normalized] = struct{}{}
|
||||
}
|
||||
p.seenMu.Unlock()
|
||||
|
||||
if exists {
|
||||
p.mu.Lock()
|
||||
p.eventsDuplicate++
|
||||
p.mu.Unlock()
|
||||
return normalized, ResultDuplicate
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.eventsProcessed++
|
||||
p.mu.Unlock()
|
||||
return normalized, ResultNew
|
||||
}
|
||||
|
||||
// Files returns a snapshot of all unique files seen so far.
|
||||
// The returned slice is sorted alphabetically.
|
||||
func (p *Processor) Files() []string {
|
||||
p.seenMu.RLock()
|
||||
defer p.seenMu.RUnlock()
|
||||
|
||||
files := make([]string, 0, len(p.seen))
|
||||
for path := range p.seen {
|
||||
files = append(files, path)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// UniqueFileCount returns the number of unique files seen.
|
||||
func (p *Processor) UniqueFileCount() int {
|
||||
p.seenMu.RLock()
|
||||
defer p.seenMu.RUnlock()
|
||||
return len(p.seen)
|
||||
}
|
||||
|
||||
// Stats returns processing statistics.
|
||||
type Stats struct {
|
||||
EventsReceived uint64
|
||||
EventsProcessed uint64
|
||||
EventsExcluded uint64
|
||||
EventsDuplicate uint64
|
||||
UniqueFiles int
|
||||
}
|
||||
|
||||
// Stats returns current processing statistics.
|
||||
func (p *Processor) Stats() Stats {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.seenMu.RLock()
|
||||
uniqueFiles := len(p.seen)
|
||||
p.seenMu.RUnlock()
|
||||
|
||||
return Stats{
|
||||
EventsReceived: p.eventsReceived,
|
||||
EventsProcessed: p.eventsProcessed,
|
||||
EventsExcluded: p.eventsExcluded,
|
||||
EventsDuplicate: p.eventsDuplicate,
|
||||
UniqueFiles: uniqueFiles,
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears all seen files and resets statistics.
|
||||
// This is primarily useful for testing.
|
||||
func (p *Processor) Reset() {
|
||||
p.seenMu.Lock()
|
||||
p.seen = make(map[string]struct{})
|
||||
p.seenMu.Unlock()
|
||||
|
||||
p.mu.Lock()
|
||||
p.eventsReceived = 0
|
||||
p.eventsProcessed = 0
|
||||
p.eventsExcluded = 0
|
||||
p.eventsDuplicate = 0
|
||||
p.mu.Unlock()
|
||||
}
|
||||
268
pkg/processor/processor_test.go
Normal file
268
pkg/processor/processor_test.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewProcessor(t *testing.T) {
|
||||
t.Run("with nil exclusions uses defaults", func(t *testing.T) {
|
||||
p := NewProcessor(nil)
|
||||
if len(p.excluded) != len(DefaultExclusions()) {
|
||||
t.Errorf("expected default exclusions, got %v", p.excluded)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with custom exclusions", func(t *testing.T) {
|
||||
exclusions := []string{"/tmp/", "/var/"}
|
||||
p := NewProcessor(exclusions)
|
||||
if len(p.excluded) != 2 {
|
||||
t.Errorf("expected 2 exclusions, got %d", len(p.excluded))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessorProcess(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
path string
|
||||
wantPath string
|
||||
wantResult ProcessResult
|
||||
}{{
|
||||
desc: "normal absolute path",
|
||||
path: "/etc/passwd",
|
||||
wantPath: "/etc/passwd",
|
||||
wantResult: ResultNew,
|
||||
}, {
|
||||
desc: "path with dots normalized",
|
||||
path: "/etc/./nginx/../passwd",
|
||||
wantPath: "/etc/passwd",
|
||||
wantResult: ResultNew,
|
||||
}, {
|
||||
desc: "proc path excluded",
|
||||
path: "/proc/self/status",
|
||||
wantPath: "/proc/self/status",
|
||||
wantResult: ResultExcluded,
|
||||
}, {
|
||||
desc: "sys path excluded",
|
||||
path: "/sys/kernel/debug",
|
||||
wantPath: "/sys/kernel/debug",
|
||||
wantResult: ResultExcluded,
|
||||
}, {
|
||||
desc: "dev path excluded",
|
||||
path: "/dev/null",
|
||||
wantPath: "/dev/null",
|
||||
wantResult: ResultExcluded,
|
||||
}, {
|
||||
desc: "empty path",
|
||||
path: "",
|
||||
wantPath: "",
|
||||
wantResult: ResultEmpty,
|
||||
}} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
p := NewProcessor(nil)
|
||||
event := &Event{
|
||||
PID: 1234,
|
||||
Path: tt.path,
|
||||
}
|
||||
|
||||
gotPath, gotResult := p.Process(event)
|
||||
if gotPath != tt.wantPath {
|
||||
t.Errorf("path = %q, want %q", gotPath, tt.wantPath)
|
||||
}
|
||||
if gotResult != tt.wantResult {
|
||||
t.Errorf("result = %v, want %v", gotResult, tt.wantResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorDeduplication(t *testing.T) {
|
||||
p := NewProcessor(nil)
|
||||
|
||||
// First access should be new
|
||||
event := &Event{PID: 1234, Path: "/etc/passwd"}
|
||||
_, result := p.Process(event)
|
||||
if result != ResultNew {
|
||||
t.Errorf("first access: got %v, want ResultNew", result)
|
||||
}
|
||||
|
||||
// Second access should be duplicate
|
||||
_, result = p.Process(event)
|
||||
if result != ResultDuplicate {
|
||||
t.Errorf("second access: got %v, want ResultDuplicate", result)
|
||||
}
|
||||
|
||||
// Third access should still be duplicate
|
||||
_, result = p.Process(event)
|
||||
if result != ResultDuplicate {
|
||||
t.Errorf("third access: got %v, want ResultDuplicate", result)
|
||||
}
|
||||
|
||||
// Different path should be new
|
||||
event2 := &Event{PID: 1234, Path: "/etc/hostname"}
|
||||
_, result = p.Process(event2)
|
||||
if result != ResultNew {
|
||||
t.Errorf("different path: got %v, want ResultNew", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorDeduplicationNormalized(t *testing.T) {
|
||||
p := NewProcessor(nil)
|
||||
|
||||
// Access with dots
|
||||
event1 := &Event{PID: 1234, Path: "/etc/./passwd"}
|
||||
_, result := p.Process(event1)
|
||||
if result != ResultNew {
|
||||
t.Errorf("first access: got %v, want ResultNew", result)
|
||||
}
|
||||
|
||||
// Access same file via different path
|
||||
event2 := &Event{PID: 1234, Path: "/etc/nginx/../passwd"}
|
||||
_, result = p.Process(event2)
|
||||
if result != ResultDuplicate {
|
||||
t.Errorf("normalized duplicate: got %v, want ResultDuplicate", result)
|
||||
}
|
||||
|
||||
// Should only have one unique file
|
||||
if p.UniqueFileCount() != 1 {
|
||||
t.Errorf("unique files = %d, want 1", p.UniqueFileCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorFiles(t *testing.T) {
|
||||
p := NewProcessor(nil)
|
||||
|
||||
paths := []string{"/etc/passwd", "/usr/bin/bash", "/lib/libc.so.6"}
|
||||
for _, path := range paths {
|
||||
p.Process(&Event{PID: 1234, Path: path})
|
||||
}
|
||||
|
||||
files := p.Files()
|
||||
sort.Strings(files)
|
||||
sort.Strings(paths)
|
||||
|
||||
if len(files) != len(paths) {
|
||||
t.Fatalf("files count = %d, want %d", len(files), len(paths))
|
||||
}
|
||||
|
||||
for i, f := range files {
|
||||
if f != paths[i] {
|
||||
t.Errorf("file[%d] = %q, want %q", i, f, paths[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorStats(t *testing.T) {
|
||||
p := NewProcessor(nil)
|
||||
|
||||
// Process various events
|
||||
p.Process(&Event{PID: 1234, Path: "/etc/passwd"}) // new
|
||||
p.Process(&Event{PID: 1234, Path: "/etc/passwd"}) // duplicate
|
||||
p.Process(&Event{PID: 1234, Path: "/etc/hostname"}) // new
|
||||
p.Process(&Event{PID: 1234, Path: "/proc/self/status"}) // excluded
|
||||
p.Process(&Event{PID: 1234, Path: ""}) // empty
|
||||
|
||||
stats := p.Stats()
|
||||
|
||||
if stats.EventsReceived != 5 {
|
||||
t.Errorf("EventsReceived = %d, want 5", stats.EventsReceived)
|
||||
}
|
||||
if stats.EventsProcessed != 2 {
|
||||
t.Errorf("EventsProcessed = %d, want 2", stats.EventsProcessed)
|
||||
}
|
||||
if stats.EventsDuplicate != 1 {
|
||||
t.Errorf("EventsDuplicate = %d, want 1", stats.EventsDuplicate)
|
||||
}
|
||||
if stats.EventsExcluded != 1 {
|
||||
t.Errorf("EventsExcluded = %d, want 1", stats.EventsExcluded)
|
||||
}
|
||||
if stats.UniqueFiles != 2 {
|
||||
t.Errorf("UniqueFiles = %d, want 2", stats.UniqueFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorReset(t *testing.T) {
|
||||
p := NewProcessor(nil)
|
||||
|
||||
p.Process(&Event{PID: 1234, Path: "/etc/passwd"})
|
||||
p.Process(&Event{PID: 1234, Path: "/etc/hostname"})
|
||||
|
||||
if p.UniqueFileCount() != 2 {
|
||||
t.Fatalf("before reset: unique files = %d, want 2", p.UniqueFileCount())
|
||||
}
|
||||
|
||||
p.Reset()
|
||||
|
||||
if p.UniqueFileCount() != 0 {
|
||||
t.Errorf("after reset: unique files = %d, want 0", p.UniqueFileCount())
|
||||
}
|
||||
|
||||
stats := p.Stats()
|
||||
if stats.EventsReceived != 0 {
|
||||
t.Errorf("after reset: EventsReceived = %d, want 0", stats.EventsReceived)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorConcurrency(t *testing.T) {
|
||||
p := NewProcessor(nil)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Simulate concurrent access from multiple goroutines
|
||||
paths := []string{
|
||||
"/etc/passwd",
|
||||
"/usr/bin/bash",
|
||||
"/lib/libc.so.6",
|
||||
"/etc/hostname",
|
||||
"/usr/lib/libssl.so",
|
||||
}
|
||||
|
||||
// Run 10 goroutines, each processing all paths 100 times
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
for _, path := range paths {
|
||||
p.Process(&Event{PID: 1234, Path: path})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should have exactly 5 unique files despite concurrent access
|
||||
if p.UniqueFileCount() != 5 {
|
||||
t.Errorf("unique files = %d, want 5", p.UniqueFileCount())
|
||||
}
|
||||
|
||||
stats := p.Stats()
|
||||
// 10 goroutines * 100 iterations * 5 paths = 5000 events
|
||||
if stats.EventsReceived != 5000 {
|
||||
t.Errorf("EventsReceived = %d, want 5000", stats.EventsReceived)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorCustomExclusions(t *testing.T) {
|
||||
// Test with custom exclusions that don't include defaults
|
||||
p := NewProcessor([]string{"/tmp/", "/custom/"})
|
||||
|
||||
// Default exclusions should NOT apply
|
||||
_, result := p.Process(&Event{PID: 1234, Path: "/proc/self/status"})
|
||||
if result != ResultNew {
|
||||
t.Errorf("/proc path: got %v, want ResultNew (custom exclusions)", result)
|
||||
}
|
||||
|
||||
// Custom exclusions SHOULD apply
|
||||
_, result = p.Process(&Event{PID: 1234, Path: "/tmp/file.txt"})
|
||||
if result != ResultExcluded {
|
||||
t.Errorf("/tmp path: got %v, want ResultExcluded", result)
|
||||
}
|
||||
|
||||
_, result = p.Process(&Event{PID: 1234, Path: "/custom/data"})
|
||||
if result != ResultExcluded {
|
||||
t.Errorf("/custom path: got %v, want ResultExcluded", result)
|
||||
}
|
||||
}
|
||||
5
plan.md
5
plan.md
|
|
@ -10,7 +10,10 @@ Milestone 1 complete, now working on Milestone 2:
|
|||
- ✅ Ring buffer event delivery from kernel to userspace
|
||||
- ✅ Go userspace loader using cilium/ebpf
|
||||
- ✅ Build infrastructure (Dockerfile, Makefile, CI)
|
||||
- ⏳ **Next**: Path normalization, deduplication, JSON reporting
|
||||
- ✅ Path normalization (resolve `.`, `..`, relative paths)
|
||||
- ✅ Configurable path exclusions
|
||||
- ✅ In-memory deduplication with efficient data structure
|
||||
- ⏳ **Next**: Periodic JSON file output, graceful shutdown
|
||||
|
||||
See [Milestone 2](#milestone-2-core-functionality) for details.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue