diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index d5cf4ca16..dec24f45a 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -735,13 +735,13 @@ func promoteYarnWorkspaceMember(techs []string) []string { } } -// promotePipToUvIn applies techutils.PromotePipToUv against dir. Callers must pass the -// project directory; an empty dir is a no-op so this never falls back to the process cwd. +// promotePipToUvIn applies techutils.PromotePipToUvForCurationAudit against dir; an empty +// dir is a no-op. Unlike 'jf audit'/'jf scan', also treats ~/.config/uv/uv.toml as a uv signal. func promotePipToUvIn(techs []string, dir string) []string { if dir == "" { return techs } - promoted := techutils.PromotePipToUv(techutils.ToTechnologies(techs), dir) + promoted := techutils.PromotePipToUvForCurationAudit(techutils.ToTechnologies(techs), dir) result := make([]string, 0, len(promoted)) for _, t := range promoted { result = append(result, t.String()) diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index db2600af5..117f5cd7b 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -6645,10 +6645,9 @@ func TestPromoteYarnWorkspaceMember(t *testing.T) { }) } -// TestPromotePipToUv covers every uv signal promotePipToUv checks (uv.lock, pyproject.toml -// [tool.uv]/[[tool.uv.index]]), confirms pip-exclusive files always win, and confirms it -// collapses a tech list already containing both pip and uv into one. Global ~/.config/uv/uv.toml -// is not a project signal and must not rewrite pip-only projects. +// TestPromotePipToUv covers every uv signal promotePipToUvIn checks for curation-audit — +// uv.lock, pyproject.toml [tool.uv], and ~/.config/uv/uv.toml — and confirms a +// pip-exclusive file always wins over all of them. func TestPromotePipToUv(t *testing.T) { pip := techutils.Pip.String() uv := techutils.Uv.String() @@ -6713,13 +6712,13 @@ func TestPromotePipToUv(t *testing.T) { expectedHasPip: true, }, { - name: "pip + ~/.config/uv/uv.toml — stays pip", - techs: []string{pip}, - hasUvToml: true, - expectedHasPip: true, + name: "pip + ~/.config/uv/uv.toml — promoted to uv", + techs: []string{pip}, + hasUvToml: true, + expectedHasUv: true, }, { - name: "pip-exclusive file is unaffected by ~/.config/uv/uv.toml — stays pip", + name: "pip-exclusive file takes priority over ~/.config/uv/uv.toml — stays pip", techs: []string{pip}, hasPipFile: "Pipfile", hasUvToml: true, diff --git a/sca/bom/buildinfo/technologies/common.go b/sca/bom/buildinfo/technologies/common.go index 59cd86d2a..9d0e1510d 100644 --- a/sca/bom/buildinfo/technologies/common.go +++ b/sca/bom/buildinfo/technologies/common.go @@ -2,9 +2,12 @@ package technologies import ( "fmt" + "os" "os/exec" "path/filepath" + "runtime" "strings" + "sync" "testing" buildInfoUtils "github.com/jfrog/build-info-go/utils" @@ -159,6 +162,97 @@ func CreateTestWorkspace(t *testing.T, sourceDir string) (string, func()) { return tests.CreateTestWorkspace(t, filepath.Join("..", "..", "..", "..", "..", "tests", "testdata", sourceDir)) } +// windowsBatchForwarderSource: tiny helper EnsureWindowsExeShim compiles and places next to +// each fake poetry/uv/pipenv ".bat", forwarding to it by explicit path via cmd.exe. +const windowsBatchForwarderSource = `package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func main() { + self, err := os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, "winbatforward: cannot resolve own path:", err) + os.Exit(1) + } + script := strings.TrimSuffix(self, filepath.Ext(self)) + ".bat" + cmd := exec.Command("cmd.exe", append([]string{"/c", script}, os.Args[1:]...)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + runErr := cmd.Run() + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + if runErr != nil { + fmt.Fprintln(os.Stderr, "winbatforward:", runErr) + os.Exit(1) + } +} +` + +var ( + windowsBatchForwarderMu sync.Mutex + windowsBatchForwarderPath string +) + +// EnsureWindowsExeShim drops a real bareName+".exe" next to a fake bareName+".bat" in +// dir, so Windows launches it reliably. No-op on other platforms. +func EnsureWindowsExeShim(t *testing.T, dir, bareName string) { + t.Helper() + if runtime.GOOS != "windows" { + return + } + forwarder, err := buildWindowsBatchForwarder() + if err != nil { + t.Fatalf("building windows batch forwarder shim: %s", err) + } + data, err := os.ReadFile(forwarder) + if err != nil { + t.Fatalf("reading windows batch forwarder shim: %s", err) + } + if err = os.WriteFile(filepath.Join(dir, bareName+".exe"), data, 0755); err != nil { // #nosec G703 -- dir is a test-controlled t.TempDir(), bareName a hardcoded literal from the caller + t.Fatalf("writing windows batch forwarder shim: %s", err) + } +} + +// buildWindowsBatchForwarder compiles the forwarder once per process (pid-scoped path) +// and caches it only on success, so a transient build failure is retried, not permanent. +func buildWindowsBatchForwarder() (string, error) { + windowsBatchForwarderMu.Lock() + defer windowsBatchForwarderMu.Unlock() + if windowsBatchForwarderPath != "" { + return windowsBatchForwarderPath, nil + } + srcFile, err := os.CreateTemp("", "winbatforward-*.go") + if err != nil { + return "", err + } + defer func() { _ = os.Remove(srcFile.Name()) }() + if _, err = srcFile.WriteString(windowsBatchForwarderSource); err != nil { + _ = srcFile.Close() + return "", err + } + if err = srcFile.Close(); err != nil { + return "", err + } + outPath := filepath.Join(os.TempDir(), fmt.Sprintf("jfrog-cli-sec-winbatforward-%d.exe", os.Getpid())) + cmd := exec.Command("go", "build", "-o", outPath, srcFile.Name()) + if out, buildErr := cmd.CombinedOutput(); buildErr != nil { + return "", fmt.Errorf("go build failed: %w: %s", buildErr, out) + } + windowsBatchForwarderPath = outPath + return windowsBatchForwarderPath, nil +} + // GetExecutableVersion gets an executable version and prints to the debug log if possible. // Only supported for package managers that use "--version". func LogExecutableVersion(executable string) { diff --git a/sca/bom/buildinfo/technologies/python/python_test.go b/sca/bom/buildinfo/technologies/python/python_test.go index ca78e0de9..506ef461d 100644 --- a/sca/bom/buildinfo/technologies/python/python_test.go +++ b/sca/bom/buildinfo/technologies/python/python_test.go @@ -904,6 +904,7 @@ func writeFakeExecutable(t *testing.T, dir, executableName, shContent, batConten } require.NoError(t, os.WriteFile(name, []byte(content), 0755)) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + technologies.EnsureWindowsExeShim(t, dir, executableName) } func TestValidateMinimumPoetryVersionUnparsableMajorErrors(t *testing.T) { diff --git a/sca/bom/buildinfo/technologies/uv/uv_test.go b/sca/bom/buildinfo/technologies/uv/uv_test.go index cb5eb2392..d4d5510da 100644 --- a/sca/bom/buildinfo/technologies/uv/uv_test.go +++ b/sca/bom/buildinfo/technologies/uv/uv_test.go @@ -1468,6 +1468,7 @@ exit /b 1 } require.NoError(t, os.WriteFile(name, []byte(script), 0755)) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + technologies.EnsureWindowsExeShim(t, dir, "uv") } const ( diff --git a/utils/techutils/techutils.go b/utils/techutils/techutils.go index 5003b5f65..975f9cb75 100644 --- a/utils/techutils/techutils.go +++ b/utils/techutils/techutils.go @@ -901,13 +901,23 @@ func promoteYarnWorkspaceMembers(technologiesDetected map[Technology]map[string] // 2. Otherwise, a project-local uv signal (uv.lock or a pyproject.toml [tool.uv] / // [[tool.uv.*]] table) → Uv wins, Pip is dropped. // -// Machine-global ~/.config/uv/uv.toml is not a project signal: it is common on developer -// machines and must not rewrite a pip-only PEP 621 project to Uv. +// ~/.config/uv/uv.toml (machine-global) is not checked here; 'jf curation-audit' uses +// PromotePipToUvForCurationAudit instead, which also honors it. // // dir is the working directory being evaluated - callers must pass it explicitly rather // than relying on the process's current directory, since a single process may evaluate // several targets without changing it. func PromotePipToUv(techs []Technology, dir string) []Technology { + return promotePipToUv(techs, dir, false) +} + +// PromotePipToUvForCurationAudit is PromotePipToUv, plus: a machine-global +// ~/.config/uv/uv.toml also counts as a uv signal, for 'jf curation-audit' only. +func PromotePipToUvForCurationAudit(techs []Technology, dir string) []Technology { + return promotePipToUv(techs, dir, true) +} + +func promotePipToUv(techs []Technology, dir string, considerGlobalUvConfig bool) []Technology { if !containsTechnology(techs, Pip) { return techs } @@ -923,6 +933,12 @@ func PromotePipToUv(techs []Technology, dir string) []Technology { } else if data, readErr := os.ReadFile(filepath.Join(dir, "pyproject.toml")); readErr == nil && (pyProjectTomlUvTableRegex.Match(data) || pyProjectTomlUvArrayTableRegex.Match(data)) { uvSignal = "pyproject.toml has uv configuration ([tool.uv] or [[tool.uv.*]])" + } else if considerGlobalUvConfig { + if home, homeErr := os.UserHomeDir(); homeErr == nil { + if _, statErr := os.Stat(filepath.Join(home, ".config", "uv", "uv.toml")); statErr == nil { + uvSignal = "~/.config/uv/uv.toml detected" + } + } } if uvSignal == "" { return techs