diff --git a/internal/services/realtimeengine/iacrealtime/constants.go b/internal/services/realtimeengine/iacrealtime/constants.go index 5d8c1f449..adb62200c 100644 --- a/internal/services/realtimeengine/iacrealtime/constants.go +++ b/internal/services/realtimeengine/iacrealtime/constants.go @@ -8,8 +8,37 @@ const ( ContainerResultsFileName = "results.json" InfoSeverity = "info" IacEnginePath = "/usr/local/bin" + + // Container engine names + engineDocker = "docker" + enginePodman = "podman" + + // engineVerifyTimeout is the timeout in seconds for verifying container engine availability + engineVerifyTimeout = 5 + + // OS constants + osLinux = "linux" ) +// macOSDockerFallbackPaths contains additional paths to check for Docker on macOS +// These paths cover various Docker installation methods: +// - /usr/local/bin/docker: Standard location (Intel Macs) +// - /opt/homebrew/bin/docker: Homebrew on Apple Silicon +// - /Applications/Docker.app/Contents/Resources/bin/docker: Docker Desktop app bundle +// - ~/.docker/bin/docker: Docker Desktop CLI tools (resolved at runtime) +// - ~/.rd/bin/docker: Rancher Desktop (resolved at runtime) +var macOSDockerFallbackPaths = []string{ + "/usr/local/bin", + "/opt/homebrew/bin", + "/Applications/Docker.app/Contents/Resources/bin", +} + +// macOSPodmanFallbackPaths contains additional paths to check for Podman on macOS +var macOSPodmanFallbackPaths = []string{ + "/usr/local/bin", + "/opt/homebrew/bin", +} + var KicsErrorCodes = []string{"60", "50", "40", "30", "20"} type LineIndex struct { diff --git a/internal/services/realtimeengine/iacrealtime/container-manager.go b/internal/services/realtimeengine/iacrealtime/container-manager.go index f42b8c870..77a833b8b 100644 --- a/internal/services/realtimeengine/iacrealtime/container-manager.go +++ b/internal/services/realtimeengine/iacrealtime/container-manager.go @@ -1,11 +1,16 @@ package iacrealtime import ( + "os" "os/exec" + "path/filepath" + "strings" "github.com/checkmarx/ast-cli/internal/commands/util" + "github.com/checkmarx/ast-cli/internal/logger" commonParams "github.com/checkmarx/ast-cli/internal/params" "github.com/google/uuid" + "github.com/pkg/errors" "github.com/spf13/viper" ) @@ -13,6 +18,7 @@ import ( type IContainerManager interface { GenerateContainerID() string RunKicsContainer(engine, volumeMap string) error + EnsureImageAvailable(engine string) (string, error) } // ContainerManager handles Docker container operations @@ -29,12 +35,129 @@ func (dm *ContainerManager) GenerateContainerID() string { return containerName } +// createCommandWithEnhancedPath creates an exec.Cmd with an enhanced PATH that includes +// Docker/Podman related directories. This is necessary on macOS when the IDE is launched +// via GUI (double-click) because it doesn't inherit the shell's PATH environment variable. +// Without this, Docker credential helpers like docker-credential-desktop won't be found. +func createCommandWithEnhancedPath(enginePath string, args ...string) *exec.Cmd { + cmd := exec.Command(enginePath, args...) + + // Only enhance PATH on macOS + if getOS() != osDarwin { + return cmd + } + + // Get current PATH + currentPath := os.Getenv("PATH") + + // Build list of additional paths to add + var additionalPaths []string + + // Add the directory containing the engine itself + engineDir := filepath.Dir(enginePath) + additionalPaths = append(additionalPaths, engineDir) + + // Add common Docker-related directories that may contain credential helpers + additionalPaths = append(additionalPaths, macOSDockerFallbackPaths...) + + // Add user home-based paths + if homeDir, err := os.UserHomeDir(); err == nil { + additionalPaths = append(additionalPaths, + filepath.Join(homeDir, ".docker", "bin"), + filepath.Join(homeDir, ".rd", "bin")) + } + + // Build a set of existing PATH entries for accurate duplicate detection + pathParts := strings.Split(currentPath, string(os.PathListSeparator)) + pathSet := make(map[string]bool) + for _, part := range pathParts { + pathSet[part] = true + } + + // Build enhanced PATH (prepend additional paths to ensure they take priority) + var enhancedPathParts []string + for _, p := range additionalPaths { + // Skip if already in PATH + if pathSet[p] { + continue + } + // Skip if directory doesn't exist + if _, err := os.Stat(p); err != nil { + continue + } + enhancedPathParts = append(enhancedPathParts, p) + } + enhancedPathParts = append(enhancedPathParts, currentPath) + enhancedPath := strings.Join(enhancedPathParts, string(os.PathListSeparator)) + + // Set the enhanced PATH in the command's environment (replace existing PATH) + env := os.Environ() + for i, e := range env { + if !strings.HasPrefix(e, "PATH=") { + continue + } + env[i] = "PATH=" + enhancedPath + break + } + cmd.Env = env + + logger.PrintIfVerbose("Enhanced PATH for container command: " + enhancedPath) + + return cmd +} + +// EnsureImageAvailable checks if the KICS image exists locally and pulls it if not available. +// Returns the resolved engine path on success. +func (dm *ContainerManager) EnsureImageAvailable(engine string) (string, error) { + logger.PrintIfVerbose("Resolving container engine: " + engine) + + resolvedEngine, err := engineNameResolution(engine, IacEnginePath) + if err != nil { + logger.PrintIfVerbose("Failed to resolve container engine '" + engine + "': " + err.Error()) + return "", errors.Wrapf(err, "container engine '%s' not found. On macOS, if Docker/Podman is installed but not found, "+ + "try launching the IDE from terminal or ensure Docker/Podman is running", engine) + } + + logger.PrintIfVerbose("Using container engine at: " + resolvedEngine) + + // Check if image exists locally using 'docker image inspect' + logger.PrintIfVerbose("Checking if KICS image exists locally: " + util.ContainerImage) + + inspectCmd := createCommandWithEnhancedPath(resolvedEngine, "image", "inspect", util.ContainerImage) + if err := inspectCmd.Run(); err == nil { + logger.PrintIfVerbose("KICS image found locally: " + util.ContainerImage) + return resolvedEngine, nil + } + + // Image not found locally, attempt to pull + logger.PrintIfVerbose("KICS image not found locally. Attempting to pull: " + util.ContainerImage) + + pullCmd := createCommandWithEnhancedPath(resolvedEngine, "pull", util.ContainerImage) + output, pullErr := pullCmd.CombinedOutput() + if pullErr != nil { + outputStr := strings.TrimSpace(string(output)) + logger.PrintIfVerbose("Failed to pull KICS image. Output: " + outputStr) + + if outputStr != "" { + return "", errors.Errorf("Failed to pull KICS image '%s': %s. Please check your network connectivity or pull the image manually using: %s pull %s", + util.ContainerImage, outputStr, resolvedEngine, util.ContainerImage) + } + return "", errors.Errorf("Failed to pull KICS image '%s': %v. Please check your network connectivity or pull the image manually using: %s pull %s", + util.ContainerImage, pullErr, resolvedEngine, util.ContainerImage) + } + + logger.PrintIfVerbose("Successfully pulled KICS image: " + util.ContainerImage) + return resolvedEngine, nil +} + func (dm *ContainerManager) RunKicsContainer(engine, volumeMap string) error { - engine, err := engineNameResolution(engine, IacEnginePath) + // Ensure the KICS image is available before running + resolvedEngine, err := dm.EnsureImageAvailable(engine) if err != nil { return err } - args := []string{ + + cmd := createCommandWithEnhancedPath(resolvedEngine, "run", "--rm", "-v", volumeMap, "--name", viper.GetString(commonParams.KicsContainerNameKey), @@ -43,8 +166,8 @@ func (dm *ContainerManager) RunKicsContainer(engine, volumeMap string) error { "-p", ContainerPath, "-o", ContainerPath, "--report-formats", ContainerFormat, - } - _, err = exec.Command(engine, args...).CombinedOutput() + ) + _, err = cmd.CombinedOutput() return err } diff --git a/internal/services/realtimeengine/iacrealtime/container-manager_test.go b/internal/services/realtimeengine/iacrealtime/container-manager_test.go index f1d93ace6..2a2261121 100644 --- a/internal/services/realtimeengine/iacrealtime/container-manager_test.go +++ b/internal/services/realtimeengine/iacrealtime/container-manager_test.go @@ -1,7 +1,9 @@ package iacrealtime import ( + "os" "os/exec" + "path/filepath" "strings" "testing" @@ -12,11 +14,14 @@ import ( // MockContainerManager for testing - does not execute real container commands type MockContainerManager struct { - GeneratedContainerIDs []string - RunKicsContainerCalls []RunKicsContainerCall - ShouldFailGenerate bool - ShouldFailRun bool - RunError error + GeneratedContainerIDs []string + RunKicsContainerCalls []RunKicsContainerCall + EnsureImageAvailableCalls []string + ShouldFailGenerate bool + ShouldFailRun bool + ShouldFailEnsureImage bool + RunError error + EnsureImageError error } type RunKicsContainerCall struct { @@ -26,8 +31,9 @@ type RunKicsContainerCall struct { func NewMockContainerManager() *MockContainerManager { return &MockContainerManager{ - GeneratedContainerIDs: make([]string, 0), - RunKicsContainerCalls: make([]RunKicsContainerCall, 0), + GeneratedContainerIDs: make([]string, 0), + RunKicsContainerCalls: make([]RunKicsContainerCall, 0), + EnsureImageAvailableCalls: make([]string, 0), } } @@ -60,6 +66,19 @@ func (m *MockContainerManager) RunKicsContainer(engine, volumeMap string) error return nil } +func (m *MockContainerManager) EnsureImageAvailable(engine string) (string, error) { + m.EnsureImageAvailableCalls = append(m.EnsureImageAvailableCalls, engine) + + if m.ShouldFailEnsureImage { + if m.EnsureImageError != nil { + return "", m.EnsureImageError + } + return "", &exec.Error{Name: engine, Err: nil} + } + + return engine, nil +} + func TestNewMockContainerManager(t *testing.T) { dm := NewMockContainerManager() @@ -265,3 +284,398 @@ func TestMockContainerManager_Integration(t *testing.T) { t.Errorf("Mock should record correct call parameters: %+v", call) } } + +func TestMockContainerManager_EnsureImageAvailable(t *testing.T) { + dm := NewMockContainerManager() + + _, err := dm.EnsureImageAvailable("docker") + if err != nil { + t.Errorf("EnsureImageAvailable should not fail by default: %v", err) + } + + if len(dm.EnsureImageAvailableCalls) != 1 { + t.Error("Mock should record EnsureImageAvailable call") + } + + if dm.EnsureImageAvailableCalls[0] != "docker" { + t.Errorf("Mock should record correct engine, got %s", dm.EnsureImageAvailableCalls[0]) + } +} + +func TestMockContainerManager_EnsureImageAvailable_Failure(t *testing.T) { + dm := NewMockContainerManager() + dm.ShouldFailEnsureImage = true + + _, err := dm.EnsureImageAvailable("docker") + if err == nil { + t.Error("EnsureImageAvailable should fail when configured to fail") + } + + // Verify call was still recorded + if len(dm.EnsureImageAvailableCalls) != 1 { + t.Error("Mock should record the call even when configured to fail") + } +} + +func TestCreateCommandWithEnhancedPath_ReturnsCommand(t *testing.T) { + cmd := createCommandWithEnhancedPath("/usr/bin/docker", "run", "--rm", "hello-world") + + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + if cmd.Path == "" { + t.Error("Command should have a path set") + } + + // Verify args are passed correctly + expectedArgs := []string{"/usr/bin/docker", "run", "--rm", "hello-world"} + if len(cmd.Args) != len(expectedArgs) { + t.Errorf("Expected %d args, got %d", len(expectedArgs), len(cmd.Args)) + } +} + +func TestCreateCommandWithEnhancedPath_NoArgs(t *testing.T) { + cmd := createCommandWithEnhancedPath("/usr/bin/docker") + + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + if len(cmd.Args) != 1 { + t.Errorf("Expected 1 arg, got %d", len(cmd.Args)) + } +} + +func TestMockContainerManager_EnsureImageAvailable_CustomError(t *testing.T) { + dm := NewMockContainerManager() + dm.ShouldFailEnsureImage = true + customErr := &exec.Error{Name: "custom", Err: nil} + dm.EnsureImageError = customErr + + _, err := dm.EnsureImageAvailable("docker") + if err != customErr { + t.Error("EnsureImageAvailable should return custom error when set") + } +} + +// ============================================================================ +// Tests for REAL ContainerManager implementation (not mock) +// ============================================================================ + +func TestNewContainerManager(t *testing.T) { + cm := NewContainerManager() + + if cm == nil { + t.Fatal("NewContainerManager() should not return nil") + } + + // Verify it implements the interface + var _ IContainerManager = cm +} + +func TestContainerManager_GenerateContainerID(t *testing.T) { + cm := &ContainerManager{} + + // Clear any existing value + viper.Set(commonParams.KicsContainerNameKey, "") + + containerName := cm.GenerateContainerID() + + // Test that a container name was generated + if containerName == "" { + t.Error("GenerateContainerID() should return a non-empty container name") + } + + // Test that it has the correct prefix + if !strings.HasPrefix(containerName, KicsContainerPrefix) { + t.Errorf("Container name should start with prefix '%s', got '%s'", KicsContainerPrefix, containerName) + } + + // Test that the UUID part exists (should be longer than just the prefix) + if len(containerName) <= len(KicsContainerPrefix) { + t.Error("Container name should include UUID after prefix") + } + + // Test that viper was set correctly + viperValue := viper.GetString(commonParams.KicsContainerNameKey) + if viperValue != containerName { + t.Errorf("Viper should be set to '%s', got '%s'", containerName, viperValue) + } + + // Test that subsequent calls generate different IDs + containerName2 := cm.GenerateContainerID() + if containerName == containerName2 { + t.Error("GenerateContainerID() should generate unique container names") + } +} + +func TestContainerManager_GenerateContainerID_UUIDFormat(t *testing.T) { + cm := &ContainerManager{} + + containerName := cm.GenerateContainerID() + + // Extract UUID part (after prefix) + uuidPart := strings.TrimPrefix(containerName, KicsContainerPrefix) + + // UUID should be 36 characters (8-4-4-4-12 format with hyphens) + if len(uuidPart) != 36 { + t.Errorf("UUID part should be 36 characters, got %d: %s", len(uuidPart), uuidPart) + } + + // Verify UUID format (contains hyphens at correct positions) + if uuidPart[8] != '-' || uuidPart[13] != '-' || uuidPart[18] != '-' || uuidPart[23] != '-' { + t.Errorf("UUID part should have hyphens at positions 8,13,18,23: %s", uuidPart) + } +} + +// ============================================================================ +// Tests for createCommandWithEnhancedPath function +// ============================================================================ + +func TestCreateCommandWithEnhancedPath_ArgsPassedCorrectly(t *testing.T) { + tests := []struct { + name string + enginePath string + args []string + expectedArgs []string + }{ + { + name: "Multiple args", + enginePath: "/usr/bin/docker", + args: []string{"run", "--rm", "-v", "/tmp:/data", "hello-world"}, + expectedArgs: []string{"/usr/bin/docker", "run", "--rm", "-v", "/tmp:/data", "hello-world"}, + }, + { + name: "Single arg", + enginePath: "/usr/bin/docker", + args: []string{"--version"}, + expectedArgs: []string{"/usr/bin/docker", "--version"}, + }, + { + name: "No args", + enginePath: "/usr/bin/podman", + args: []string{}, + expectedArgs: []string{"/usr/bin/podman"}, + }, + { + name: "Args with special characters", + enginePath: "/usr/local/bin/docker", + args: []string{"run", "--env", "VAR=value with spaces"}, + expectedArgs: []string{"/usr/local/bin/docker", "run", "--env", "VAR=value with spaces"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := createCommandWithEnhancedPath(tt.enginePath, tt.args...) + + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + if len(cmd.Args) != len(tt.expectedArgs) { + t.Errorf("Expected %d args, got %d", len(tt.expectedArgs), len(cmd.Args)) + } + + for i, expected := range tt.expectedArgs { + if i < len(cmd.Args) && cmd.Args[i] != expected { + t.Errorf("Arg[%d]: expected %q, got %q", i, expected, cmd.Args[i]) + } + } + }) + } +} + +func TestCreateCommandWithEnhancedPath_CommandPath(t *testing.T) { + tests := []struct { + name string + enginePath string + }{ + { + name: "Absolute path", + enginePath: "/usr/bin/docker", + }, + { + name: "Relative path", + enginePath: "./docker", + }, + { + name: "Just command name", + enginePath: "docker", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := createCommandWithEnhancedPath(tt.enginePath, "--version") + + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + // The first arg should always be the engine path + if len(cmd.Args) < 1 || cmd.Args[0] != tt.enginePath { + t.Errorf("First arg should be engine path %q, got %v", tt.enginePath, cmd.Args) + } + }) + } +} + +func TestCreateCommandWithEnhancedPath_EnvIsSet(t *testing.T) { + cmd := createCommandWithEnhancedPath("/usr/bin/docker", "run") + + // On non-macOS, Env might be nil (uses parent env) + // On macOS, Env should be set with enhanced PATH + // This test verifies the command is created without error + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + // If Env is set, verify PATH is present + if cmd.Env != nil { + foundPath := false + for _, e := range cmd.Env { + if !strings.HasPrefix(e, "PATH=") { + continue + } + foundPath = true + break + } + if !foundPath { + t.Error("If Env is set, it should contain PATH") + } + } +} + +// ============================================================================ +// Tests for createCommandWithEnhancedPath on macOS (mocked) +// ============================================================================ + +func TestCreateCommandWithEnhancedPath_MacOS_EnhancesPath(t *testing.T) { + // Mock OS to be macOS + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + // Create a temp directory that exists (to be added to PATH) + tempDir := t.TempDir() + enginePath := filepath.Join(tempDir, "docker") + + cmd := createCommandWithEnhancedPath(enginePath, "run", "--rm") + + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + // On macOS, Env should be set + if cmd.Env == nil { + t.Error("On macOS, cmd.Env should be set with enhanced PATH") + } + + // Verify PATH is in the environment + foundPath := false + for _, e := range cmd.Env { + if !strings.HasPrefix(e, "PATH=") { + continue + } + foundPath = true + // Verify the engine directory is in the PATH + if !strings.Contains(e, tempDir) { + t.Errorf("Enhanced PATH should contain engine directory %s, got %s", tempDir, e) + } + break + } + if !foundPath { + t.Error("Enhanced PATH should contain PATH= entry") + } +} + +func TestCreateCommandWithEnhancedPath_MacOS_AddsDockerPaths(t *testing.T) { + // Mock OS to be macOS + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + cmd := createCommandWithEnhancedPath("/usr/local/bin/docker", "--version") + + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + // On macOS, Env should be set + if cmd.Env == nil { + t.Error("On macOS, cmd.Env should be set") + } +} + +func TestCreateCommandWithEnhancedPath_MacOS_DeduplicatesPaths(t *testing.T) { + // Mock OS to be macOS + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + // Set PATH to include one of the fallback paths + oldPath := os.Getenv("PATH") + defer func() { _ = os.Setenv("PATH", oldPath) }() + _ = os.Setenv("PATH", "/usr/local/bin:/usr/bin") + + cmd := createCommandWithEnhancedPath("/usr/local/bin/docker", "--version") + + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + // Verify PATH doesn't have duplicates + for _, e := range cmd.Env { + if !strings.HasPrefix(e, "PATH=") { + continue + } + pathValue := strings.TrimPrefix(e, "PATH=") + parts := strings.Split(pathValue, string(os.PathListSeparator)) + seen := make(map[string]int) + for _, p := range parts { + seen[p]++ + if seen[p] > 1 { + t.Errorf("PATH contains duplicate entry: %s", p) + } + } + break + } +} + +func TestCreateCommandWithEnhancedPath_NonMacOS_NoEnhancement(t *testing.T) { + // Mock OS to be Linux + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osLinux } + + cmd := createCommandWithEnhancedPath("/usr/bin/docker", "run") + + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + // On non-macOS, Env should be nil (uses parent environment) + if cmd.Env != nil { + t.Error("On non-macOS, cmd.Env should be nil") + } +} + +func TestCreateCommandWithEnhancedPath_Windows_NoEnhancement(t *testing.T) { + // Mock OS to be Windows + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osWindows } + + cmd := createCommandWithEnhancedPath("C:\\Program Files\\Docker\\docker.exe", "run") + + if cmd == nil { + t.Fatal("createCommandWithEnhancedPath should not return nil") + } + + // On Windows, Env should be nil (uses parent environment) + if cmd.Env != nil { + t.Error("On Windows, cmd.Env should be nil") + } +} diff --git a/internal/services/realtimeengine/iacrealtime/iac-realtime.go b/internal/services/realtimeengine/iacrealtime/iac-realtime.go index 17f46ffb2..078276033 100644 --- a/internal/services/realtimeengine/iacrealtime/iac-realtime.go +++ b/internal/services/realtimeengine/iacrealtime/iac-realtime.go @@ -1,12 +1,14 @@ package iacrealtime import ( + "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "runtime" + "time" "github.com/checkmarx/ast-cli/internal/services/realtimeengine" "github.com/checkmarx/ast-cli/internal/wrappers" @@ -147,21 +149,98 @@ func (svc *IacRealtimeService) validateFilePath(filePath string) error { } func engineNameResolution(engineName, fallBackDir string) (string, error) { - var err error - if _, err = exec.LookPath(engineName); err == nil { + // First, try to find the engine in PATH (works when launched from terminal) + if _, err := exec.LookPath(engineName); err == nil { return engineName, nil } - if err != nil && getOS() == osWindows { + + // On Windows, we don't have fallback paths - the engine must be in PATH + if getOS() == osWindows { return "", errors.New(engineName + ": executable file not found in PATH") } - fallbackPath := filepath.Join(fallBackDir, engineName) - info, err := os.Stat(fallbackPath) - if err == nil && !info.IsDir() { - return fallbackPath, nil + + // On macOS/Linux, check multiple fallback paths + // This handles the case when IDE is launched via GUI and doesn't inherit shell PATH + fallbackPaths := getFallbackPaths(engineName, fallBackDir) + + for _, fallbackPath := range fallbackPaths { + if verifyEnginePath(fallbackPath) { + return fallbackPath, nil + } + } + + checkedPaths := make([]string, len(fallbackPaths)) + copy(checkedPaths, fallbackPaths) + return "", errors.Errorf("%s not found in PATH or in fallback locations: %v", engineName, checkedPaths) +} + +// getFallbackPaths returns a list of paths to check for the container engine +func getFallbackPaths(engineName, fallBackDir string) []string { + var paths []string + + // Add the primary fallback directory + paths = append(paths, filepath.Join(fallBackDir, engineName)) + + // On macOS, add additional paths based on engine type + if getOS() == osDarwin { + var additionalPaths []string + switch engineName { + case engineDocker: + additionalPaths = macOSDockerFallbackPaths + case enginePodman: + additionalPaths = macOSPodmanFallbackPaths + default: + // Unknown engine, no additional paths + } + + for _, dir := range additionalPaths { + enginePath := filepath.Join(dir, engineName) + // Skip duplicates + if enginePath == filepath.Join(fallBackDir, engineName) { + continue + } + paths = append(paths, enginePath) + } + + // Add user home-based paths + if homeDir, err := os.UserHomeDir(); err == nil { + switch engineName { + case engineDocker: + paths = append(paths, + filepath.Join(homeDir, ".docker", "bin", engineDocker), + filepath.Join(homeDir, ".rd", "bin", engineDocker)) + case enginePodman: + paths = append(paths, filepath.Join(homeDir, ".local", "bin", enginePodman)) + default: + // Unknown engine, no home-based paths + } + } + } + + return paths +} + +// verifyEnginePath checks if the engine exists and is executable at the given path +func verifyEnginePath(enginePath string) bool { + info, err := os.Stat(enginePath) + if err != nil || info.IsDir() { + return false } - return "", errors.New(engineName + " not found in PATH or in " + IacEnginePath) + + // Verify the engine can be executed with a timeout to prevent hanging + ctx, cancel := context.WithTimeout(context.Background(), engineVerifyTimeout*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, enginePath, "--version") + if err := cmd.Run(); err != nil { + return false + } + + return true } +const osDarwin = "darwin" + var getOS = func() string { return runtime.GOOS } diff --git a/internal/services/realtimeengine/iacrealtime/iac-realtime_test.go b/internal/services/realtimeengine/iacrealtime/iac-realtime_test.go index 1ce51c145..88a271e55 100644 --- a/internal/services/realtimeengine/iacrealtime/iac-realtime_test.go +++ b/internal/services/realtimeengine/iacrealtime/iac-realtime_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" commonParams "github.com/checkmarx/ast-cli/internal/params" @@ -11,6 +12,11 @@ import ( "github.com/checkmarx/ast-cli/internal/wrappers/mock" ) +const ( + testFallbackDir = "/test/fallback" + testDifferentFallbackDir = "/different/fallback" +) + func TestNewIacRealtimeService(t *testing.T) { mockJWT := &mock.JWTMockWrapper{} mockFlags := &mock.FeatureFlagsMockWrapper{} @@ -510,3 +516,698 @@ func TestEngineName_Resolution_check_fallBackPath_for_MAC_Linux(t *testing.T) { t.Fatalf("expected %q, got %q", expected, result) } } + +func TestGetFallbackPaths_Docker_Darwin(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + fallbackDir := testFallbackDir + paths := getFallbackPaths(engineDocker, fallbackDir) + + // Should contain primary fallback path + expectedPrimary := filepath.Join(fallbackDir, engineDocker) + found := false + for _, p := range paths { + if p != expectedPrimary { + continue + } + found = true + break + } + if !found { + t.Errorf("Expected primary fallback path %s in paths", expectedPrimary) + } + + // Should contain macOS Docker fallback paths + for _, macPath := range macOSDockerFallbackPaths { + expectedPath := filepath.Join(macPath, engineDocker) + if expectedPath == expectedPrimary { + continue // Skip if same as primary + } + found = false + for _, p := range paths { + if p != expectedPath { + continue + } + found = true + break + } + if !found { + t.Errorf("Expected macOS fallback path %s in paths", expectedPath) + } + } +} + +func TestGetFallbackPaths_Podman_Darwin(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + fallbackDir := testFallbackDir + paths := getFallbackPaths(enginePodman, fallbackDir) + + // Should contain primary fallback path + expectedPrimary := filepath.Join(fallbackDir, enginePodman) + found := false + for _, p := range paths { + if p != expectedPrimary { + continue + } + found = true + break + } + if !found { + t.Errorf("Expected primary fallback path %s in paths", expectedPrimary) + } + + // Should contain macOS Podman fallback paths + for _, macPath := range macOSPodmanFallbackPaths { + expectedPath := filepath.Join(macPath, enginePodman) + if expectedPath == expectedPrimary { + continue // Skip if same as primary + } + found = false + for _, p := range paths { + if p != expectedPath { + continue + } + found = true + break + } + if !found { + t.Errorf("Expected macOS fallback path %s in paths", expectedPath) + } + } +} + +func TestGetFallbackPaths_NonDarwin(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osLinux } + + fallbackDir := testFallbackDir + paths := getFallbackPaths(engineDocker, fallbackDir) + + // On non-darwin, should only contain primary fallback path + if len(paths) != 1 { + t.Errorf("Expected 1 path on non-darwin, got %d", len(paths)) + } + + expectedPrimary := filepath.Join(fallbackDir, engineDocker) + if paths[0] != expectedPrimary { + t.Errorf("Expected %s, got %s", expectedPrimary, paths[0]) + } +} + +func TestGetFallbackPaths_UnknownEngine_Darwin(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + fallbackDir := testFallbackDir + paths := getFallbackPaths("unknown-engine", fallbackDir) + + // Should only contain primary fallback path for unknown engine + expectedPrimary := filepath.Join(fallbackDir, "unknown-engine") + if len(paths) != 1 { + t.Errorf("Expected 1 path for unknown engine, got %d", len(paths)) + } + if paths[0] != expectedPrimary { + t.Errorf("Expected %s, got %s", expectedPrimary, paths[0]) + } +} + +func TestVerifyEnginePath_NonExistentPath(t *testing.T) { + result := verifyEnginePath("/non/existent/path/to/engine") + if result { + t.Error("verifyEnginePath should return false for non-existent path") + } +} + +func TestVerifyEnginePath_Directory(t *testing.T) { + tempDir := t.TempDir() + result := verifyEnginePath(tempDir) + if result { + t.Error("verifyEnginePath should return false for directory") + } +} + +func TestGetFallbackPaths_Docker_Darwin_IncludesHomePaths(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + fallbackDir := testDifferentFallbackDir + paths := getFallbackPaths(engineDocker, fallbackDir) + + // Get user home directory + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skipf("Cannot get user home directory: %v", err) + } + + // Should contain home-based Docker paths + expectedDockerHome := filepath.Join(homeDir, ".docker", "bin", engineDocker) + expectedRdHome := filepath.Join(homeDir, ".rd", "bin", engineDocker) + + foundDockerHome := false + foundRdHome := false + for _, p := range paths { + if p == expectedDockerHome { + foundDockerHome = true + } + if p == expectedRdHome { + foundRdHome = true + } + } + + if !foundDockerHome { + t.Errorf("Expected Docker home path %s in paths", expectedDockerHome) + } + if !foundRdHome { + t.Errorf("Expected Rancher Desktop home path %s in paths", expectedRdHome) + } +} + +func TestGetFallbackPaths_Podman_Darwin_IncludesHomePaths(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + fallbackDir := testDifferentFallbackDir + paths := getFallbackPaths(enginePodman, fallbackDir) + + // Get user home directory + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skipf("Cannot get user home directory: %v", err) + } + + // Should contain home-based Podman path + expectedPodmanHome := filepath.Join(homeDir, ".local", "bin", enginePodman) + + foundPodmanHome := false + for _, p := range paths { + if p == expectedPodmanHome { + foundPodmanHome = true + } + } + + if !foundPodmanHome { + t.Errorf("Expected Podman home path %s in paths", expectedPodmanHome) + } +} + +func TestGetFallbackPaths_Windows(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osWindows } + + fallbackDir := "C:\\test\\fallback" + paths := getFallbackPaths(engineDocker, fallbackDir) + + // On Windows, should only contain primary fallback path + if len(paths) != 1 { + t.Errorf("Expected 1 path on Windows, got %d", len(paths)) + } + + expectedPrimary := filepath.Join(fallbackDir, engineDocker) + if paths[0] != expectedPrimary { + t.Errorf("Expected %s, got %s", expectedPrimary, paths[0]) + } +} + +// ============================================================================ +// Additional tests for getFallbackPaths function +// ============================================================================ + +func TestGetFallbackPaths_NoDuplicates(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + // Use /usr/local/bin as fallback dir (same as one of macOSDockerFallbackPaths) + fallbackDir := "/usr/local/bin" + paths := getFallbackPaths(engineDocker, fallbackDir) + + // Check for duplicates + seen := make(map[string]bool) + for _, p := range paths { + if seen[p] { + t.Errorf("Duplicate path found: %s", p) + } + seen[p] = true + } +} + +func TestGetFallbackPaths_PrimaryPathFirst(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osDarwin } + + fallbackDir := "/custom/fallback" + paths := getFallbackPaths(engineDocker, fallbackDir) + + if len(paths) == 0 { + t.Fatal("Expected at least one path") + } + + expectedPrimary := filepath.Join(fallbackDir, engineDocker) + if paths[0] != expectedPrimary { + t.Errorf("Primary fallback path should be first. Expected %s, got %s", expectedPrimary, paths[0]) + } +} + +func TestGetFallbackPaths_EmptyFallbackDir(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osLinux } + + paths := getFallbackPaths(engineDocker, "") + + if len(paths) != 1 { + t.Errorf("Expected 1 path with empty fallback dir, got %d", len(paths)) + } + + // Should just be the engine name + if paths[0] != engineDocker { + t.Errorf("Expected %s, got %s", engineDocker, paths[0]) + } +} + +// ============================================================================ +// Additional tests for verifyEnginePath function +// ============================================================================ + +func TestVerifyEnginePath_EmptyPath(t *testing.T) { + result := verifyEnginePath("") + if result { + t.Error("verifyEnginePath should return false for empty path") + } +} + +func TestVerifyEnginePath_FileWithoutExecutePermission(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("Skipping permission test on Windows") + } + + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "non-executable") + + // Create file without execute permission + err := os.WriteFile(testFile, []byte("not executable"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + result := verifyEnginePath(testFile) + if result { + t.Error("verifyEnginePath should return false for non-executable file") + } +} + +func TestVerifyEnginePath_SymlinkToNonExistent(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("Skipping symlink test on Windows") + } + + tempDir := t.TempDir() + symlinkPath := filepath.Join(tempDir, "broken-symlink") + + // Create symlink to non-existent target + err := os.Symlink("/non/existent/target", symlinkPath) + if err != nil { + t.Skipf("Cannot create symlink: %v", err) + } + + result := verifyEnginePath(symlinkPath) + if result { + t.Error("verifyEnginePath should return false for broken symlink") + } +} + +// ============================================================================ +// Additional tests for engineNameResolution function +// ============================================================================ + +func TestEngineNameResolution_WindowsNotInPath(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osWindows } + + // Save and clear PATH + oldPath := os.Getenv("PATH") + defer func() { _ = os.Setenv("PATH", oldPath) }() + _ = os.Setenv("PATH", "") + + _, err := engineNameResolution("docker", "/fallback") + + if err == nil { + t.Error("engineNameResolution should return error on Windows when engine not in PATH") + } + + expectedMsg := "docker: executable file not found in PATH" + if err.Error() != expectedMsg { + t.Errorf("Expected error message %q, got %q", expectedMsg, err.Error()) + } +} + +func TestEngineNameResolution_FallbackPathsChecked(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("Skipping fallback path test on Windows") + } + + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osLinux } + + // Save and clear PATH + oldPath := os.Getenv("PATH") + defer func() { _ = os.Setenv("PATH", oldPath) }() + _ = os.Setenv("PATH", "") + + // Use a non-existent fallback directory + _, err := engineNameResolution("docker", "/non/existent/path") + + if err == nil { + t.Error("engineNameResolution should return error when engine not found") + } + + // Error should mention fallback locations + if !strings.Contains(err.Error(), "fallback locations") { + t.Errorf("Error should mention fallback locations: %v", err) + } +} + +func TestEngineNameResolution_EmptyEngineName(t *testing.T) { + origGOOS := getOS + defer func() { getOS = origGOOS }() + getOS = func() string { return osLinux } + + // Save and clear PATH + oldPath := os.Getenv("PATH") + defer func() { _ = os.Setenv("PATH", oldPath) }() + _ = os.Setenv("PATH", "") + + _, err := engineNameResolution("", "/fallback") + + if err == nil { + t.Error("engineNameResolution should return error for empty engine name") + } +} + +// ============================================================================ +// Tests for buildIgnoreMap function (direct tests) +// ============================================================================ + +func TestBuildIgnoreMap_Empty(t *testing.T) { + ignored := []IgnoredIacFinding{} + result := buildIgnoreMap(ignored) + + if len(result) != 0 { + t.Errorf("Expected empty map, got %d entries", len(result)) + } +} + +func TestBuildIgnoreMap_SingleEntry(t *testing.T) { + ignored := []IgnoredIacFinding{ + { + Title: "Test Finding", + SimilarityID: "abc123", + }, + } + result := buildIgnoreMap(ignored) + + if len(result) != 1 { + t.Errorf("Expected 1 entry, got %d", len(result)) + } + + expectedKey := "Test Finding_abc123" + if !result[expectedKey] { + t.Errorf("Expected key %q to be true", expectedKey) + } +} + +func TestBuildIgnoreMap_MultipleEntries(t *testing.T) { + ignored := []IgnoredIacFinding{ + {Title: "Finding1", SimilarityID: "id1"}, + {Title: "Finding2", SimilarityID: "id2"}, + {Title: "Finding3", SimilarityID: "id3"}, + } + result := buildIgnoreMap(ignored) + + if len(result) != 3 { + t.Errorf("Expected 3 entries, got %d", len(result)) + } + + expectedKeys := []string{"Finding1_id1", "Finding2_id2", "Finding3_id3"} + for _, key := range expectedKeys { + if !result[key] { + t.Errorf("Expected key %q to be present", key) + } + } +} + +func TestBuildIgnoreMap_DuplicateEntries(t *testing.T) { + ignored := []IgnoredIacFinding{ + {Title: "Same", SimilarityID: "same"}, + {Title: "Same", SimilarityID: "same"}, + } + result := buildIgnoreMap(ignored) + + // Duplicates should result in single entry + if len(result) != 1 { + t.Errorf("Expected 1 entry for duplicates, got %d", len(result)) + } +} + +// ============================================================================ +// Tests for filterIgnoredFindings function (additional cases) +// ============================================================================ + +func TestFilterIgnoredFindings_EmptyResults(t *testing.T) { + results := []IacRealtimeResult{} + ignoreMap := map[string]bool{"key": true} + + filtered := filterIgnoredFindings(results, ignoreMap) + + if len(filtered) != 0 { + t.Errorf("Expected empty result, got %d", len(filtered)) + } +} + +func TestFilterIgnoredFindings_EmptyIgnoreMap(t *testing.T) { + results := []IacRealtimeResult{ + {Title: "Finding1", SimilarityID: "id1"}, + {Title: "Finding2", SimilarityID: "id2"}, + } + ignoreMap := map[string]bool{} + + filtered := filterIgnoredFindings(results, ignoreMap) + + if len(filtered) != 2 { + t.Errorf("Expected 2 results, got %d", len(filtered)) + } +} + +func TestFilterIgnoredFindings_AllIgnored(t *testing.T) { + results := []IacRealtimeResult{ + {Title: "Finding1", SimilarityID: "id1"}, + {Title: "Finding2", SimilarityID: "id2"}, + } + ignoreMap := map[string]bool{ + "Finding1_id1": true, + "Finding2_id2": true, + } + + filtered := filterIgnoredFindings(results, ignoreMap) + + if len(filtered) != 0 { + t.Errorf("Expected 0 results when all ignored, got %d", len(filtered)) + } +} + +func TestFilterIgnoredFindings_PartialMatch(t *testing.T) { + results := []IacRealtimeResult{ + {Title: "Finding1", SimilarityID: "id1"}, + {Title: "Finding2", SimilarityID: "id2"}, + {Title: "Finding3", SimilarityID: "id3"}, + } + ignoreMap := map[string]bool{ + "Finding2_id2": true, + } + + filtered := filterIgnoredFindings(results, ignoreMap) + + if len(filtered) != 2 { + t.Errorf("Expected 2 results, got %d", len(filtered)) + } + + // Verify correct findings remain + titles := make(map[string]bool) + for _, r := range filtered { + titles[r.Title] = true + } + + if !titles["Finding1"] || !titles["Finding3"] { + t.Error("Wrong findings were filtered") + } + if titles["Finding2"] { + t.Error("Finding2 should have been filtered out") + } +} + +func TestFilterIgnoredFindings_PreservesOrder(t *testing.T) { + results := []IacRealtimeResult{ + {Title: "A", SimilarityID: "1"}, + {Title: "B", SimilarityID: "2"}, + {Title: "C", SimilarityID: "3"}, + {Title: "D", SimilarityID: "4"}, + } + ignoreMap := map[string]bool{ + "B_2": true, + } + + filtered := filterIgnoredFindings(results, ignoreMap) + + if len(filtered) != 3 { + t.Fatalf("Expected 3 results, got %d", len(filtered)) + } + + // Verify order is preserved + expectedOrder := []string{"A", "C", "D"} + for i, expected := range expectedOrder { + if filtered[i].Title != expected { + t.Errorf("Position %d: expected %s, got %s", i, expected, filtered[i].Title) + } + } +} + +// ============================================================================ +// Tests for loadIgnoredIacFindings function +// ============================================================================ + +func TestLoadIgnoredIacFindings_ValidFile(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "ignored.json") + + // Create valid JSON file + content := `[ + {"title": "Finding1", "similarityId": "id1"}, + {"title": "Finding2", "similarityId": "id2"} + ]` + err := os.WriteFile(testFile, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + findings, err := loadIgnoredIacFindings(testFile) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(findings) != 2 { + t.Errorf("Expected 2 findings, got %d", len(findings)) + } + + if findings[0].Title != "Finding1" || findings[0].SimilarityID != "id1" { + t.Errorf("First finding mismatch: %+v", findings[0]) + } +} + +func TestLoadIgnoredIacFindings_EmptyArray(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "ignored.json") + + err := os.WriteFile(testFile, []byte("[]"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + findings, err := loadIgnoredIacFindings(testFile) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(findings) != 0 { + t.Errorf("Expected 0 findings, got %d", len(findings)) + } +} + +func TestLoadIgnoredIacFindings_InvalidJSON(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "ignored.json") + + err := os.WriteFile(testFile, []byte("not valid json"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + _, err = loadIgnoredIacFindings(testFile) + + if err == nil { + t.Error("Expected error for invalid JSON, got nil") + } +} + +func TestLoadIgnoredIacFindings_FileNotFound(t *testing.T) { + _, err := loadIgnoredIacFindings("/non/existent/file.json") + + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } +} + +func TestLoadIgnoredIacFindings_EmptyFile(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "ignored.json") + + err := os.WriteFile(testFile, []byte(""), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + _, err = loadIgnoredIacFindings(testFile) + + if err == nil { + t.Error("Expected error for empty file, got nil") + } +} + +// ============================================================================ +// Additional tests for verifyEnginePath function - testing with real executable +// ============================================================================ + +func TestVerifyEnginePath_DirectoryInsteadOfFile(t *testing.T) { + tempDir := t.TempDir() + + result := verifyEnginePath(tempDir) + if result { + t.Error("verifyEnginePath should return false for directory path") + } +} + +func TestVerifyEnginePath_ValidSystemExecutable(t *testing.T) { + // This test only runs on Windows because: + // - On Linux, common executables like /bin/sh don't properly support --version flag + // - The verifyEnginePath function is designed for Docker/Podman which do support --version + // - We have other tests covering the main code paths (directory check, file existence, etc.) + if runtime.GOOS != osWindows { + t.Skip("Skipping on non-Windows: system executables may not support --version flag") + } + + execPath := "C:\\Windows\\System32\\cmd.exe" + + // Check if the executable exists first + if _, err := os.Stat(execPath); err != nil { + t.Skipf("System executable not found: %s", execPath) + } + + result := verifyEnginePath(execPath) + if !result { + t.Errorf("verifyEnginePath should return true for valid executable: %s", execPath) + } +}