mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix: pip args & manifest files handling (#41)
* fix: pip args & manifest files handling * chore: add pflag dependency * chore: word fixes * fix: multiple refs for suspicious packages * refactor: introduce block config * fix: makefile for windows build * test: add test cases for manifest based installation * refactor: Makefile * refactor: remove .exe for windows build
This commit is contained in:
@@ -83,7 +83,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
|
||||
}
|
||||
|
||||
// If install command was found but no explicit packages,
|
||||
// this is a manifest-based installation (install from package.json)
|
||||
// this is a manifest-based installation
|
||||
if foundInstallCmd && len(packages) == 0 {
|
||||
isManifestInstall = true
|
||||
}
|
||||
@@ -119,17 +119,11 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
|
||||
})
|
||||
}
|
||||
|
||||
var manifestFiles []string
|
||||
if isManifestInstall {
|
||||
// npm/pnpm installs from package.json by default
|
||||
manifestFiles = append(manifestFiles, "package.json")
|
||||
}
|
||||
|
||||
return &ParsedCommand{
|
||||
Command: command,
|
||||
InstallTargets: installTargets,
|
||||
IsManifestInstall: isManifestInstall,
|
||||
ManifestFiles: manifestFiles,
|
||||
ManifestFiles: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
+27
-87
@@ -109,6 +109,33 @@ func TestNpmParseCommand(t *testing.T) {
|
||||
assert.Equal(t, "1.2.3", parsedCommand.InstallTargets[1].PackageVersion.Version)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "manifest installation (bare install)",
|
||||
command: "npm install",
|
||||
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(parsedCommand.InstallTargets))
|
||||
assert.Equal(t, true, parsedCommand.IsManifestInstall)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "manifest installation (short form)",
|
||||
command: "npm i",
|
||||
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(parsedCommand.InstallTargets))
|
||||
assert.Equal(t, true, parsedCommand.IsManifestInstall)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "npm install with flags but no packages",
|
||||
command: "npm install --save-dev",
|
||||
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(parsedCommand.InstallTargets))
|
||||
assert.Equal(t, true, parsedCommand.IsManifestInstall)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -121,90 +148,3 @@ func TestNpmParseCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNpmParseCommand_ManifestInstallation(t *testing.T) {
|
||||
pm, err := NewNpmPackageManager(DefaultNpmPackageManagerConfig())
|
||||
assert.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedManifest bool
|
||||
expectedFiles []string
|
||||
expectedTargets int
|
||||
}{
|
||||
{
|
||||
name: "npm install without args (bare install)",
|
||||
args: []string{"install"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"package.json"},
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "npm i without args (short form)",
|
||||
args: []string{"i"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"package.json"},
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "npm install with explicit package",
|
||||
args: []string{"install", "react"},
|
||||
expectedManifest: false,
|
||||
expectedFiles: nil,
|
||||
expectedTargets: 1,
|
||||
},
|
||||
{
|
||||
name: "npm install with multiple packages",
|
||||
args: []string{"install", "react", "vue"},
|
||||
expectedManifest: false,
|
||||
expectedFiles: nil,
|
||||
expectedTargets: 2,
|
||||
},
|
||||
{
|
||||
name: "npm install with flags but no packages",
|
||||
args: []string{"install", "--save-dev"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"package.json"},
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "npm install with mixed args",
|
||||
args: []string{"install", "react", "--save"},
|
||||
expectedManifest: false,
|
||||
expectedFiles: nil,
|
||||
expectedTargets: 1,
|
||||
},
|
||||
{
|
||||
name: "non-install command",
|
||||
args: []string{"run", "build"},
|
||||
expectedManifest: false,
|
||||
expectedFiles: nil,
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "pnpm install without args",
|
||||
args: []string{"install"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"package.json"},
|
||||
expectedTargets: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
parsed, err := pm.ParseCommand(tc.args)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expectedManifest, parsed.IsManifestInstall, "IsManifestInstall mismatch")
|
||||
assert.Equal(t, tc.expectedFiles, parsed.ManifestFiles, "ManifestFiles mismatch")
|
||||
assert.Equal(t, tc.expectedTargets, len(parsed.InstallTargets), "InstallTargets count mismatch")
|
||||
|
||||
// Test helper methods
|
||||
assert.Equal(t, tc.expectedManifest, parsed.HasManifestInstall(), "HasManifestInstall mismatch")
|
||||
|
||||
expectedShouldExtract := tc.expectedManifest && tc.expectedTargets == 0
|
||||
assert.Equal(t, expectedShouldExtract, parsed.ShouldExtractFromManifest(), "ShouldExtractFromManifest mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+44
-61
@@ -2,10 +2,13 @@ package packagemanager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
)
|
||||
|
||||
@@ -42,78 +45,61 @@ func (pip *pipPackageManager) Ecosystem() packagev1.Ecosystem {
|
||||
}
|
||||
|
||||
func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error) {
|
||||
// Remove 'pip' if it's the first argument
|
||||
if len(args) > 0 && args[0] == "pip" {
|
||||
args = args[1:]
|
||||
}
|
||||
|
||||
command := Command{Exe: pip.Config.CommandName, Args: args}
|
||||
|
||||
// Since manifest-based installs like 'npm i' are now valid commands
|
||||
if len(args) < 1 {
|
||||
return &ParsedCommand{Command: command}, nil
|
||||
}
|
||||
|
||||
// Find the install command
|
||||
var installCmdIndex = -1
|
||||
for idx, arg := range args {
|
||||
if slices.Contains(pip.Config.InstallCommands, arg) {
|
||||
installCmdIndex = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if installCmdIndex == -1 {
|
||||
// No install command found, return as-is
|
||||
return &ParsedCommand{Command: command}, nil
|
||||
}
|
||||
|
||||
// Extract arguments after the install command
|
||||
installArgs := args[installCmdIndex+1:]
|
||||
|
||||
fs := pflag.NewFlagSet("pip", pflag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
// Define flags
|
||||
var requirementFiles []string
|
||||
fs.StringArrayVarP(&requirementFiles, "requirement", "r", nil, "Install from requirement file")
|
||||
|
||||
// Parse arguments (supports interleaved flags + positional args)
|
||||
err := fs.Parse(installArgs)
|
||||
if err != nil {
|
||||
return &ParsedCommand{
|
||||
Command: command,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var packages []string
|
||||
var manifestFiles []string
|
||||
var isManifestInstall bool
|
||||
var foundInstallCmd bool
|
||||
// Get remaining arguments (package names)
|
||||
packages := fs.Args()
|
||||
|
||||
for idx, arg := range args {
|
||||
if slices.Contains(pip.Config.InstallCommands, arg) {
|
||||
foundInstallCmd = true
|
||||
// Check for manifest-based installation flags
|
||||
for i := idx + 1; i < len(args); i++ {
|
||||
currentArg := args[i]
|
||||
// Determine if this is a manifest install
|
||||
isManifestInstall := len(requirementFiles) > 0
|
||||
|
||||
// Handle -r/--requirement flags
|
||||
if currentArg == "-r" || currentArg == "--requirement" {
|
||||
isManifestInstall = true
|
||||
if i+1 < len(args) {
|
||||
manifestFiles = append(manifestFiles, args[i+1])
|
||||
i++ // skip the filename
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle combined -r flag (e.g., -rrequirements.txt)
|
||||
if strings.HasPrefix(currentArg, "-r") && len(currentArg) > 2 {
|
||||
isManifestInstall = true
|
||||
manifestFiles = append(manifestFiles, currentArg[2:])
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle other flags that indicate manifest installation
|
||||
if currentArg == "-e" || currentArg == "--editable" ||
|
||||
currentArg == "-c" || currentArg == "--constraint" {
|
||||
if i+1 < len(args) {
|
||||
i++ // skip the next argument
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's a flag, skip it
|
||||
if strings.HasPrefix(currentArg, "-") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, it's a package name
|
||||
packages = append(packages, currentArg)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If install command was found but no explicit packages and no manifest flags,
|
||||
// check if it's a bare "pip install" (which should look for default manifest files)
|
||||
if foundInstallCmd && len(packages) == 0 && len(manifestFiles) == 0 {
|
||||
isManifestInstall = true
|
||||
// pip install without args typically looks for requirements.txt
|
||||
manifestFiles = append(manifestFiles, "requirements.txt")
|
||||
}
|
||||
// Combine all manifest files
|
||||
var allManifestFiles []string
|
||||
allManifestFiles = append(allManifestFiles, requirementFiles...)
|
||||
|
||||
// Process packages
|
||||
var installTargets []*PackageInstallTarget
|
||||
|
||||
for _, pkg := range packages {
|
||||
packageName, version, extras, err := pipParsePackageInfo(pkg)
|
||||
if err != nil {
|
||||
@@ -121,12 +107,9 @@ func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error
|
||||
}
|
||||
|
||||
if version != "" {
|
||||
// If exact version provided just trim it. If not get a version that satisfies a given version specifier
|
||||
if strings.HasPrefix(version, "==") {
|
||||
// Exact version, just trim
|
||||
version = strings.TrimPrefix(version, "==")
|
||||
} else {
|
||||
// Version range, resolve from PyPI
|
||||
version, err = pipGetMatchingVersion(packageName, version)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error resolving version for %s: %s", packageName, err.Error())
|
||||
@@ -150,7 +133,7 @@ func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error
|
||||
Command: command,
|
||||
InstallTargets: installTargets,
|
||||
IsManifestInstall: isManifestInstall,
|
||||
ManifestFiles: manifestFiles,
|
||||
ManifestFiles: allManifestFiles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
+56
-49
@@ -111,72 +111,79 @@ func TestPipParsePackageInfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipParseCommand_ManifestInstallation(t *testing.T) {
|
||||
func TestPipParseCommand(t *testing.T) {
|
||||
pm, err := NewPipPackageManager(DefaultPipPackageManagerConfig())
|
||||
assert.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedManifest bool
|
||||
expectedFiles []string
|
||||
expectedTargets int
|
||||
name string
|
||||
args []string
|
||||
expectedManifest bool
|
||||
expectedFiles []string
|
||||
expectedTargets int
|
||||
}{
|
||||
{
|
||||
name: "pip install with -r flag",
|
||||
args: []string{"install", "-r", "requirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt"},
|
||||
expectedTargets: 0,
|
||||
name: "pip install with -r flag",
|
||||
args: []string{"install", "-r", "requirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt"},
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "pip install with --requirement flag",
|
||||
args: []string{"install", "--requirement", "requirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt"},
|
||||
expectedTargets: 0,
|
||||
name: "pip install with -r flag with different filename",
|
||||
args: []string{"install", "-r", "requirements-dev.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements-dev.txt"},
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "pip install with combined -r flag",
|
||||
args: []string{"install", "-rrequirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt"},
|
||||
expectedTargets: 0,
|
||||
name: "pip install with --requirement flag",
|
||||
args: []string{"install", "--requirement", "requirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt"},
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "pip install without args (bare install)",
|
||||
args: []string{"install"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt"},
|
||||
expectedTargets: 0,
|
||||
name: "pip install with combined -r flag",
|
||||
args: []string{"install", "-rrequirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt"},
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "pip install with explicit package",
|
||||
args: []string{"install", "django"},
|
||||
expectedManifest: false,
|
||||
expectedFiles: nil,
|
||||
expectedTargets: 1,
|
||||
name: "pip install without args",
|
||||
args: []string{"install"},
|
||||
expectedManifest: false,
|
||||
expectedFiles: nil,
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "pip install with mixed args",
|
||||
args: []string{"install", "django", "-r", "requirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt"},
|
||||
expectedTargets: 1,
|
||||
name: "pip install with explicit package",
|
||||
args: []string{"install", "django"},
|
||||
expectedManifest: false,
|
||||
expectedFiles: nil,
|
||||
expectedTargets: 1,
|
||||
},
|
||||
{
|
||||
name: "pip install with multiple -r flags",
|
||||
args: []string{"install", "-r", "requirements.txt", "-r", "dev-requirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt", "dev-requirements.txt"},
|
||||
expectedTargets: 0,
|
||||
name: "pip install with mixed args",
|
||||
args: []string{"install", "django", "-r", "requirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt"},
|
||||
expectedTargets: 1,
|
||||
},
|
||||
{
|
||||
name: "non-install command",
|
||||
args: []string{"list"},
|
||||
expectedManifest: false,
|
||||
expectedFiles: nil,
|
||||
expectedTargets: 0,
|
||||
name: "pip install with multiple -r flags",
|
||||
args: []string{"install", "-r", "requirements.txt", "-r", "dev-requirements.txt"},
|
||||
expectedManifest: true,
|
||||
expectedFiles: []string{"requirements.txt", "dev-requirements.txt"},
|
||||
expectedTargets: 0,
|
||||
},
|
||||
{
|
||||
name: "non-install command",
|
||||
args: []string{"list"},
|
||||
expectedManifest: false,
|
||||
expectedFiles: nil,
|
||||
expectedTargets: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -184,14 +191,14 @@ func TestPipParseCommand_ManifestInstallation(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
parsed, err := pm.ParseCommand(tc.args)
|
||||
assert.NoError(t, err)
|
||||
|
||||
|
||||
assert.Equal(t, tc.expectedManifest, parsed.IsManifestInstall, "IsManifestInstall mismatch")
|
||||
assert.Equal(t, tc.expectedFiles, parsed.ManifestFiles, "ManifestFiles mismatch")
|
||||
assert.Equal(t, tc.expectedTargets, len(parsed.InstallTargets), "InstallTargets count mismatch")
|
||||
|
||||
|
||||
// Test helper methods
|
||||
assert.Equal(t, tc.expectedManifest, parsed.HasManifestInstall(), "HasManifestInstall mismatch")
|
||||
|
||||
|
||||
expectedShouldExtract := tc.expectedManifest && tc.expectedTargets == 0
|
||||
assert.Equal(t, expectedShouldExtract, parsed.ShouldExtractFromManifest(), "ShouldExtractFromManifest mismatch")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user