diff --git a/internal/resolution/pm/nuget/cmd_factory.go b/internal/resolution/pm/nuget/cmd_factory.go index cdb3e665..3bb423d7 100644 --- a/internal/resolution/pm/nuget/cmd_factory.go +++ b/internal/resolution/pm/nuget/cmd_factory.go @@ -1,8 +1,16 @@ package nuget import ( + "bytes" + "encoding/xml" + "html/template" + "io" + "os" "os/exec" "path/filepath" + "regexp" + "sort" + "strings" ) type ICmdFactory interface { @@ -20,13 +28,55 @@ func (ExecPath) LookPath(file string) (string, error) { return exec.LookPath(file) } +var packagesConfigTemplate = ` + + + {{.TargetFrameworks}} + + + {{- range .Packages}} + + {{- end}} + + +` + type CmdFactory struct { - execPath IExecPath + execPath IExecPath + packageConfgRegex string + packagesConfigTemplate string +} + +func NewCmdFactory(execPath IExecPath) CmdFactory { + return CmdFactory{ + execPath: execPath, + packageConfgRegex: PackagesConfigRegex, + packagesConfigTemplate: packagesConfigTemplate, + } } func (cmdf CmdFactory) MakeInstallCmd(command string, file string) (*exec.Cmd, error) { + + // If the file is a packages.config file, convert it to a .csproj file + // check regex with PackagesConfigRegex + packageConfig, err := regexp.Compile(cmdf.packageConfgRegex) + if err != nil { + return nil, err + } + + if packageConfig.MatchString(file) { + file, err = cmdf.convertPackagesConfigToCsproj(file) + if err != nil { + return nil, err + } + } + path, err := cmdf.execPath.LookPath(command) + if err != nil { + return nil, err + } + fileDir := filepath.Dir(file) return &exec.Cmd{ @@ -37,3 +87,112 @@ func (cmdf CmdFactory) MakeInstallCmd(command string, file string) (*exec.Cmd, e Dir: fileDir, }, err } + +type Packages struct { + Packages []Package `xml:"package"` +} + +type Package struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + TargetFramework string `xml:"targetFramework,attr"` +} + +// convertPackagesConfigtoCsproj converts a packages.config file to a .csproj file +// this is to enable the use of the dotnet restore command +// that enables debricked to parse out transitive dependencies. +// This may add some additional framework dependencies that will not show up if +// we only scan the packages.config file. +func (cmdf CmdFactory) convertPackagesConfigToCsproj(filePath string) (string, error) { + packages, err := parsePackagesConfig(filePath) + if err != nil { + return "", err + } + + targetFrameworksStr := collectUniqueTargetFrameworks(packages.Packages) + csprojContent, err := cmdf.createCsprojContentWithTemplate(targetFrameworksStr, packages.Packages) + if err != nil { + return "", err + } + + newFilename := filePath + ".csproj" + err = writeContentToCsprojFile(newFilename, csprojContent) + if err != nil { + return "", err + } + + return newFilename, nil +} + +var ioReadAllCsproj = io.ReadAll + +func parsePackagesConfig(filePath string) (*Packages, error) { + xmlFile, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer xmlFile.Close() + + byteValue, err := ioReadAllCsproj(xmlFile) + if err != nil { + return nil, err + } + + var packages Packages + err = xml.Unmarshal(byteValue, &packages) + if err != nil { + return nil, err + } + + return &packages, nil +} + +func collectUniqueTargetFrameworks(packages []Package) string { + uniqueTargetFrameworks := make(map[string]struct{}) + for _, pkg := range packages { + uniqueTargetFrameworks[pkg.TargetFramework] = struct{}{} + } + + var targetFrameworks []string + for framework := range uniqueTargetFrameworks { + if framework != "" { + targetFrameworks = append(targetFrameworks, framework) + } + } + + sort.Strings(targetFrameworks) // Sort the targetFrameworks slice + + return strings.Join(targetFrameworks, ";") +} +func (cmdf CmdFactory) createCsprojContentWithTemplate(targetFrameworksStr string, packages []Package) (string, error) { + tmplParsed, err := template.New("csproj").Parse(cmdf.packagesConfigTemplate) + if err != nil { + return "", err + } + + var tpl bytes.Buffer + err = tmplParsed.Execute(&tpl, map[string]interface{}{ + "TargetFrameworks": targetFrameworksStr, + "Packages": packages, + }) + if err != nil { + return "", err + } + + return tpl.String(), nil +} + +var osCreateCsproj = os.Create + +func writeContentToCsprojFile(newFilename string, content string) error { + + csprojFile, err := osCreateCsproj(newFilename) + if err != nil { + return err + } + defer csprojFile.Close() + + _, err = csprojFile.WriteString(content) + + return err +} diff --git a/internal/resolution/pm/nuget/cmd_factory_test.go b/internal/resolution/pm/nuget/cmd_factory_test.go index 8bb78d4f..5679ddff 100644 --- a/internal/resolution/pm/nuget/cmd_factory_test.go +++ b/internal/resolution/pm/nuget/cmd_factory_test.go @@ -1,19 +1,411 @@ package nuget import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestMakeInstallCmd(t *testing.T) { - nugetCommand := "dotnet" - cmd, err := CmdFactory{ - execPath: ExecPath{}, - }.MakeInstallCmd(nugetCommand, "file") + cmd, err := NewCmdFactory( + ExecPath{}, + ).MakeInstallCmd(nuget, "file") + assert.NoError(t, err) + assert.NotNil(t, cmd) + args := cmd.Args + assert.Contains(t, args, "dotnet") + assert.Contains(t, args, "restore") +} + +func TestMakeInstallCmdPackagsConfig(t *testing.T) { + + cmd, err := NewCmdFactory( + ExecPath{}, + ).MakeInstallCmd(nuget, "testdata/valid/packages.config") assert.NoError(t, err) assert.NotNil(t, cmd) args := cmd.Args assert.Contains(t, args, "dotnet") assert.Contains(t, args, "restore") + + // Cleanup: Remove the created .csproj file + if err := os.Remove("testdata/valid/packages.config.csproj"); err != nil { + t.Fatalf("Failed to remove test file: %v", err) + } +} + +func MockReadAll(r io.Reader) ([]byte, error) { + return nil, fmt.Errorf("mock error") +} +func TestParsePackagesConfig(t *testing.T) { + tests := []struct { + name string + setup func() string // function to set up the test environment + teardown func() // function to clean up after the test + shouldFail bool + }{ + { + name: "Non-existent file", + setup: func() string { + return "nonexistent_file.config" + }, + shouldFail: true, + }, + { + name: "Unreadable file", + setup: func() string { + file, err := os.CreateTemp("", "unreadable_*.config") + if err != nil { + t.Fatal(err) + } + err = file.Chmod(0222) // write-only permissions + if err != nil { + t.Fatal(err) + } + + return file.Name() + }, + teardown: func() { + os.Remove("unreadable_file.config") // clean up the unreadable file + }, + shouldFail: true, + }, + { + name: "Malformed XML", + setup: func() string { + file, err := os.CreateTemp("", "malformed_*.config") + if err != nil { + t.Fatal(err) + } + _, err = file.WriteString("malformed xml content") + if err != nil { + t.Fatal(err) + } + + return file.Name() + }, + teardown: func() { + os.Remove("malformed_file.config") // clean up the malformed file + }, + shouldFail: true, + }, + { + name: "ReadALL error", + setup: func() string { + ioReadAllCsproj = MockReadAll + + return "testdata/valid/packages.config" + }, + teardown: func() { + ioReadAllCsproj = io.ReadAll + }, + shouldFail: true, + }, + { + name: "Valid packages.config", + setup: func() string { + + return "testdata/valid/packages.config" + }, + teardown: func() { + fmt.Println("teardown") + }, + shouldFail: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + filePath := test.setup() + if test.teardown != nil { + defer test.teardown() // clean up after the test + } + + _, err := parsePackagesConfig(filePath) + if (err != nil) != test.shouldFail { + t.Errorf("parsePackagesConfig() error = %v, shouldFail = %v", err, test.shouldFail) + } + }) + } +} + +func TestCollectUniqueTargetFrameworks(t *testing.T) { + packages := []Package{ + {TargetFramework: "net45"}, + {TargetFramework: "net46"}, + {TargetFramework: "net45"}, + } + got := collectUniqueTargetFrameworks(packages) + want := "net45;net46" + if got != want { + t.Errorf("collectUniqueTargetFrameworks() = %v, want %v", got, want) + } +} + +func TestWriteContentToCsprojFile(t *testing.T) { + newFilename := "testdata/test_output.csproj" + content := "" + + if err := writeContentToCsprojFile(newFilename, content); err != nil { + t.Fatalf("writeContentToCsprojFile() failed: %v", err) + } + + if _, err := os.Stat(newFilename); os.IsNotExist(err) { + t.Fatalf("writeContentToCsprojFile() did not create file") + } + + // Cleanup: Remove the created file + if err := os.Remove(newFilename); err != nil { + t.Fatalf("Failed to remove test file: %v", err) + } +} + +func TestWriteContentToCsprojFileErr(t *testing.T) { + tests := []struct { + name string + filename string + content string + shouldFail bool + setup func() // function to set up the environment for the test + teardown func() // function to clean up after the test + }{ + { + name: "Valid file name and content", + filename: "test.csproj", + content: "", + shouldFail: false, + teardown: func() { + os.Remove("test.csproj") // Clean up the created file + }, + }, + { + name: "Invalid file name", + filename: "", // Empty filename is invalid + content: "", + shouldFail: true, + }, + { + name: "Write to a read-only file", + filename: "readonly.csproj", + content: "", + shouldFail: true, + setup: func() { + // Create a read-only file + file, err := os.Create("readonly.csproj") + if err != nil { + panic(err) + } + file.Close() + err = os.Chmod("readonly.csproj", 0444) // Set file permissions to read-only + if err != nil { + panic(err) + } + + }, + teardown: func() { + os.Remove("readonly.csproj") // Clean up the read-only file + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.setup != nil { + test.setup() // set up the environment for the test + } + err := writeContentToCsprojFile(test.filename, test.content) + if (err != nil) != test.shouldFail { + t.Errorf("writeContentToCsprojFile() error = %v, shouldFail = %v", err, test.shouldFail) + } + if test.teardown != nil { + test.teardown() // clean up after the test + } + }) + } +} + +func TestCreateCsprojContent(t *testing.T) { + tests := []struct { + name string + targetFrameworksStr string + packages []Package + shouldFail bool + tmpl string + }{ + { + name: "Valid template action", + targetFrameworksStr: "netcoreapp3.1", + packages: []Package{{ID: "SomePackage", Version: "1.0.0"}}, + shouldFail: false, + tmpl: packagesConfigTemplate, + }, + { + name: "Invalid template action", + targetFrameworksStr: "netcoreapp3.1", + packages: []Package{{ID: "SomePackage", Version: "1.0.0"}}, + shouldFail: true, + tmpl: ` + + + {{.TargetFrameworks}} + + + {{- range .Packages}} + + {{- end}} + + + `, + }, + { + name: "Non-existent field", + targetFrameworksStr: "netcoreapp3.1", + packages: []Package{{ID: "SomePackage", Version: "1.0.0"}}, + shouldFail: true, + tmpl: ` + + + {{.NonExistentField}} + + + {{- range .Packages}} + + {{- end}} + + + `, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := CmdFactory{ + execPath: ExecPath{}, + packageConfgRegex: PackagesConfigRegex, + packagesConfigTemplate: test.tmpl, + } + _, err := cmd.createCsprojContentWithTemplate(test.targetFrameworksStr, test.packages) + if (err != nil) != test.shouldFail { + t.Errorf("createCsprojContentWithTemplate() error = %v, shouldFail = %v", err, test.shouldFail) + } + }) + } +} + +func TestMakeInstallCmdBadPackagesConfigRegex(t *testing.T) { + + cmd, err := CmdFactory{ + execPath: ExecPath{}, + packageConfgRegex: "[", + }.MakeInstallCmd(nuget, "file") + + assert.Error(t, err) + assert.Nil(t, cmd) +} + +func TestMakeInstallCmdNotAccessToFile(t *testing.T) { + + tempDir, err := os.MkdirTemp("", "TestMakeInstallCmdNotAccessToFile") + if err != nil { + panic(err) + } + defer os.RemoveAll(tempDir) + + filePath := filepath.Join(tempDir, "packages.config") + + file, err := os.Create(filePath) + if err != nil { + panic(err) + } + defer file.Close() + + err = file.Chmod(0222) // write-only permissions + + if err != nil { + panic(err) + } + + _, err = NewCmdFactory( + ExecPath{}, + ).MakeInstallCmd(nuget, file.Name()) + + assert.Error(t, err) +} + +type ExecPathErr struct { +} + +func (ExecPathErr) LookPath(file string) (string, error) { + return "", errors.New("error") +} + +func TestMakeInstallCmdExecPathError(t *testing.T) { + + cmd, err := CmdFactory{ + execPath: ExecPathErr{}, + packageConfgRegex: PackagesConfigRegex, + }.MakeInstallCmd(nuget, "file") + + assert.Error(t, err) + assert.Nil(t, cmd) +} + +// Define a mock function that always returns an error +func mockCreate(name string) (*os.File, error) { + return nil, fmt.Errorf("mock error") +} +func TestConvertPackagesConfigToCsproj(t *testing.T) { + tests := []struct { + name string + filePath string + wantError bool + packagesConfigTemplate string + setup func() // function to set up the test environment + teardown func() // function to clean up after the test + }{ + {"Valid packages config", "testdata/valid/packages.config", false, packagesConfigTemplate, nil, nil}, + {"Invalid packages config", "testdata/invalid/packages.config", true, packagesConfigTemplate, nil, nil}, + {"Bad template", "testdata/valid/packages.config", true, "{{.TargetFramewo", nil, nil}, + {"File without write premisions", "testdata/valid/packages.config", true, packagesConfigTemplate, + func() { + osCreateCsproj = mockCreate + }, + func() { + osCreateCsproj = os.Create + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + tt.setup() // set up the environment for the test + } + + if tt.teardown != nil { + defer tt.teardown() // clean up after the test + } + + cmd := CmdFactory{ + execPath: ExecPath{}, + packageConfgRegex: PackagesConfigRegex, + packagesConfigTemplate: tt.packagesConfigTemplate, + } + _, err := cmd.convertPackagesConfigToCsproj(tt.filePath) + if (err != nil) != tt.wantError { + t.Errorf("convertPackagesConfigToCsproj(%q) = %v, want error: %v", tt.filePath, err, tt.wantError) + } + + }) + } + + // Cleanup: Remove the created .csproj file + if err := os.Remove("testdata/valid/packages.config.csproj"); err != nil { + t.Fatalf("Failed to remove test file: %v", err) + } } diff --git a/internal/resolution/pm/nuget/pm.go b/internal/resolution/pm/nuget/pm.go index 21d43d60..8012f732 100644 --- a/internal/resolution/pm/nuget/pm.go +++ b/internal/resolution/pm/nuget/pm.go @@ -1,6 +1,8 @@ package nuget const Name = "nuget" +const CsprojRegex = `\.csproj$` +const PackagesConfigRegex = `packages\.config$` type Pm struct { name string @@ -18,6 +20,7 @@ func (pm Pm) Name() string { func (Pm) Manifests() []string { return []string{ - `\.csproj$`, + CsprojRegex, + PackagesConfigRegex, } } diff --git a/internal/resolution/pm/nuget/pm_test.go b/internal/resolution/pm/nuget/pm_test.go index 4e53bc79..ff762f41 100644 --- a/internal/resolution/pm/nuget/pm_test.go +++ b/internal/resolution/pm/nuget/pm_test.go @@ -20,10 +20,15 @@ func TestName(t *testing.T) { func TestManifests(t *testing.T) { pm := Pm{} manifests := pm.Manifests() - assert.Len(t, manifests, 1) - manifest := manifests[0] - assert.Equal(t, `\.csproj$`, manifest) - _, err := regexp.Compile(manifest) + assert.Len(t, manifests, 2) + manifestCs := manifests[0] + assert.Equal(t, `\.csproj$`, manifestCs) + _, err := regexp.Compile(manifestCs) + assert.NoError(t, err) + + manifestPc := manifests[1] + assert.Equal(t, `packages\.config$`, manifestPc) + _, err = regexp.Compile(manifestPc) assert.NoError(t, err) cases := map[string]bool{ @@ -34,11 +39,16 @@ func TestManifests(t *testing.T) { "test.csproj.nuget": false, "test.csproj.nuget.props": false, "package.json.lock": false, + "packages.config": true, } for file, isMatch := range cases { t.Run(file, func(t *testing.T) { - matched, _ := regexp.MatchString(manifest, file) - assert.Equal(t, isMatch, matched) + + matchedCs, _ := regexp.MatchString(manifestCs, file) + matchedPc, _ := regexp.MatchString(manifestPc, file) + matched := matchedCs || matchedPc + + assert.Equal(t, isMatch, matched, "file: %s", file) }) } } diff --git a/internal/resolution/pm/nuget/strategy.go b/internal/resolution/pm/nuget/strategy.go index 355c1ccd..0d4cd48c 100644 --- a/internal/resolution/pm/nuget/strategy.go +++ b/internal/resolution/pm/nuget/strategy.go @@ -14,9 +14,7 @@ func (s Strategy) Invoke() ([]job.IJob, error) { jobs = append(jobs, NewJob( file, true, - CmdFactory{ - execPath: ExecPath{}, - }, + NewCmdFactory(ExecPath{}), ), ) } diff --git a/internal/resolution/pm/nuget/testdata/invalid/packages.config b/internal/resolution/pm/nuget/testdata/invalid/packages.config new file mode 100644 index 00000000..e2ed7284 --- /dev/null +++ b/internal/resolution/pm/nuget/testdata/invalid/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/internal/resolution/pm/nuget/testdata/valid/packages.config b/internal/resolution/pm/nuget/testdata/valid/packages.config new file mode 100644 index 00000000..69f628d7 --- /dev/null +++ b/internal/resolution/pm/nuget/testdata/valid/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file