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
6 changes: 3 additions & 3 deletions commands/curation/curationaudit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
17 changes: 8 additions & 9 deletions commands/curation/curationaudit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions sca/bom/buildinfo/technologies/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions sca/bom/buildinfo/technologies/python/python_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions sca/bom/buildinfo/technologies/uv/uv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
20 changes: 18 additions & 2 deletions utils/techutils/techutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
Loading