Skip to content

Commit 2954eee

Browse files
authored
Merge pull request #121 from fossas/feat/nuget-support
feat(builders): add nuget support
2 parents c168cce + 8d58e9c commit 2954eee

File tree

11 files changed

+12532
-11
lines changed

11 files changed

+12532
-11
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
**Features:**
2323

24-
- Supports [over 15+ languages & environments](docs/how-it-works.md) (JavaScript, Java, Ruby, Golang, PHP, etc...)
24+
- Supports [over 18+ languages & environments](docs/how-it-works.md) (JavaScript, Java, Ruby, Python, Golang, PHP, .NET, etc...)
2525
- Auto-configures for monoliths; instantly handles multiple builds in large codebases
2626
- Fast & portable; a cross-platform binary you can drop into CI or dev machines
2727
- Generates offline documentation for license notices & third-party attributions

builders/builder.go

+2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ func New(moduleType module.Type) Builder {
3737
return &MavenBuilder{}
3838
case module.Nodejs:
3939
return &NodeJSBuilder{}
40+
case module.NuGet:
41+
return &NuGetBuilder{}
4042
case module.Pip:
4143
return &PipBuilder{}
4244
case module.Ruby:

builders/nuget.go

+240
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package builders
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io/ioutil"
7+
"os"
8+
"path/filepath"
9+
"regexp"
10+
"strings"
11+
12+
"github.com/bmatcuk/doublestar"
13+
logging "github.com/op/go-logging"
14+
15+
"github.com/fossas/fossa-cli/module"
16+
)
17+
18+
var nugetLogger = logging.MustGetLogger("nuget")
19+
20+
// NuGetPackage implements Dependency for NuGet
21+
type NuGetPackage struct {
22+
Name string `json:"name"`
23+
Version string `json:"version"`
24+
}
25+
26+
// Fetcher always returns gem for NuGetPackage
27+
func (m NuGetPackage) Fetcher() string {
28+
return "nuget"
29+
}
30+
31+
// Package returns the package spec for NuGetPackage
32+
func (m NuGetPackage) Package() string {
33+
return m.Name
34+
}
35+
36+
// Revision returns the version spec for NuGetPackage
37+
func (m NuGetPackage) Revision() string {
38+
return m.Version
39+
}
40+
41+
type nuGetLockfileV2or3 struct {
42+
Libraries map[string]struct{} `json:"libraries"`
43+
}
44+
45+
// NuGetBuilder implements Builder for Bundler (Gemfile) builds
46+
type NuGetBuilder struct {
47+
DotNETCmd string
48+
DotNETVersion string
49+
NuGetCmd string
50+
NuGetVersion string
51+
}
52+
53+
// Initialize collects metadata on NuGet and .NET environments
54+
func (builder *NuGetBuilder) Initialize() error {
55+
nugetLogger.Debug("Initializing NuGet builder...")
56+
57+
// Set DotNET context variables
58+
dotNetCmd, dotNetVersion, err := which("--version", os.Getenv("DOTNET_BINARY"), "dotnet")
59+
if err != nil {
60+
nugetLogger.Warningf("Could not find `dotnet` binary (try setting $DOTNET_BINARY): %s", err.Error())
61+
}
62+
builder.DotNETCmd = dotNetCmd
63+
builder.DotNETVersion = strings.TrimRight(dotNetVersion, "\n")
64+
65+
// Set NuGet context variables
66+
nuGetCmd, nuGetVersonOut, err := which("help", os.Getenv("NUGET_BINARY"), "nuget")
67+
if err == nil {
68+
builder.NuGetCmd = nuGetCmd
69+
70+
nuGetVersionMatchRe := regexp.MustCompile(`NuGet Version: ([0-9]+\.[0-9]+\.[0-9]+.\w+)`)
71+
match := nuGetVersionMatchRe.FindStringSubmatch(nuGetVersonOut)
72+
if len(match) == 2 {
73+
builder.NuGetVersion = match[1]
74+
}
75+
} else {
76+
nugetLogger.Warningf("Could not find NuGet binary (try setting $NUGET_BINARY): %s", err.Error())
77+
}
78+
79+
nugetLogger.Debugf("Initialized NuGet builder: %#v", builder)
80+
return nil
81+
}
82+
83+
// Build runs `dotnet restore` and falls back to `nuget restore`
84+
func (builder *NuGetBuilder) Build(m module.Module, force bool) error {
85+
nugetLogger.Debugf("Running NuGet build: %#v %#v", m, force)
86+
87+
if builder.DotNETCmd != "" {
88+
dotNetSuccessKey := "Restore completed"
89+
dotNetStdout, dotNetStderr, err := runLogged(nugetLogger, m.Dir, builder.DotNETCmd, "restore")
90+
if err == nil && (strings.Contains(dotNetStdout, dotNetSuccessKey) || strings.Contains(dotNetStderr, dotNetSuccessKey)) {
91+
nugetLogger.Debug("NuGet build succeeded with `dotnet restore`.")
92+
return nil
93+
}
94+
}
95+
96+
nugetLogger.Debug("`dotnet restore` did not succeed, falling back to `nuget restore`")
97+
98+
if builder.NuGetCmd != "" {
99+
pkgDir, _ := resolveNugetPackagesDir(m.Dir)
100+
_, _, err := runLogged(nugetLogger, m.Dir, builder.NuGetCmd, "restore", "-PackagesDirectory", pkgDir)
101+
if err != nil {
102+
return fmt.Errorf("could not run `nuget install`: %s", err.Error())
103+
}
104+
} else {
105+
return errors.New("No tools installed in local environment for NuGet build")
106+
}
107+
108+
nugetLogger.Debug("Done running NuGet build.")
109+
return nil
110+
}
111+
112+
// Analyze parses the output of NuGet lockfiles and falls back to parsing the packages folder
113+
func (builder *NuGetBuilder) Analyze(m module.Module, allowUnresolved bool) ([]module.Dependency, error) {
114+
nugetLogger.Debugf("Running NuGet analysis: %#v %#v", m, allowUnresolved)
115+
116+
deps := []module.Dependency{}
117+
118+
// Find and parse a lockfile
119+
lockFilePath, err := resolveNuGetProjectLockfile(m.Dir)
120+
if err == nil {
121+
var lockFile nuGetLockfileV2or3
122+
if err := parseLogged(nugetLogger, lockFilePath, &lockFile); err == nil {
123+
for depKey := range lockFile.Libraries {
124+
depKeyParts := strings.Split(depKey, "/")
125+
if len(depKeyParts) == 2 {
126+
deps = append(deps, module.Dependency(NuGetPackage{
127+
Name: depKeyParts[0],
128+
Version: depKeyParts[1],
129+
}))
130+
}
131+
}
132+
}
133+
} else {
134+
// Fallback to parsing the packages directory
135+
packagesDir, err := resolveNugetPackagesDir(m.Dir)
136+
137+
nugetLogger.Debugf("No lockfile found; parsing packages directory: %s", packagesDir)
138+
if exists, err := hasFile(packagesDir); err != nil || !exists {
139+
return nil, fmt.Errorf("Unable to verify packages directory: %s", packagesDir)
140+
}
141+
142+
packagePaths, err := ioutil.ReadDir(packagesDir)
143+
if err != nil {
144+
return nil, err
145+
}
146+
147+
for _, f := range packagePaths {
148+
packageNameRe := regexp.MustCompile(`(([A-z]+\.?)+)\.(([0-9]+\.)+[\w-]+)`)
149+
match := packageNameRe.FindStringSubmatch(f.Name())
150+
nugetLogger.Debugf("%s, %V", len(match), match)
151+
if len(match) == 5 {
152+
deps = append(deps, module.Dependency(NuGetPackage{
153+
Name: match[1],
154+
Version: match[3],
155+
}))
156+
}
157+
}
158+
}
159+
160+
// TODO: filter out system deps
161+
162+
nugetLogger.Debugf("Done running NuGet analysis: %#v", deps)
163+
return deps, nil
164+
}
165+
166+
// IsBuilt checks the existance of a lockfile or a packages directory
167+
func (builder *NuGetBuilder) IsBuilt(m module.Module, allowUnresolved bool) (bool, error) {
168+
if allowUnresolved {
169+
return true, nil
170+
}
171+
172+
nugetLogger.Debug("Checking NuGet module directory for a project lockfile")
173+
if _, err := resolveNuGetProjectLockfile(m.Dir); err != nil {
174+
nugetLogger.Debug("Checking NuGet packages directory for existence")
175+
176+
packagesDir, _ := resolveNugetPackagesDir(m.Dir)
177+
return hasFile(packagesDir)
178+
}
179+
180+
return true, nil
181+
}
182+
183+
// IsModule is not implemented
184+
func (builder *NuGetBuilder) IsModule(target string) (bool, error) {
185+
return false, errors.New("IsModule is not implemented for NuGetBuilder")
186+
}
187+
188+
// resolveNugetPackagesDir parses a NuGet module config and resolves it to an existing package directory
189+
func resolveNugetPackagesDir(dir string) (string, error) {
190+
packagesDir := filepath.Join(dir, "packages")
191+
return packagesDir, fmt.Errorf("unable to resolve NuGet packages directory: %s", "Not Implemented.")
192+
}
193+
194+
func resolveNuGetProjectLockfile(dir string) (string, error) {
195+
lockfilePathCandidates := []string{"project.lock.json", "obj/project.assets.json"}
196+
for _, path := range lockfilePathCandidates {
197+
nugetLogger.Debugf("Checking for lockfile: %s/%s", dir, path)
198+
if hasLockfile, err := hasFile(dir, path); hasLockfile && err == nil {
199+
return filepath.Join(dir, path), nil
200+
}
201+
}
202+
203+
return "", fmt.Errorf("No lockfiles detected in directory root: %s", dir)
204+
}
205+
206+
// DiscoverModules returns ModuleConfigs that match `packages.config` in the directory
207+
func (builder *NuGetBuilder) DiscoverModules(dir string) ([]module.Config, error) {
208+
packageRecordPaths, err := doublestar.Glob(filepath.Join(dir, "**", "{*.csproj,*.xproj,*.vbproj,*.dbproj,*.fsproj,packages.config,project.json,*.nuspec}"))
209+
if err != nil {
210+
return nil, err
211+
}
212+
moduleConfigs := make([]module.Config, 0)
213+
for _, path := range packageRecordPaths {
214+
packageName := filepath.Base(filepath.Dir(path))
215+
// infer title from *.nuspec in directory if exists
216+
nuSpecs, err := doublestar.Glob(filepath.Join(filepath.Dir(path), "*.nuspec"))
217+
if err == nil && len(nuSpecs) > 0 {
218+
packageName = strings.TrimRight(filepath.Base(nuSpecs[0]), ".nuspec")
219+
}
220+
path, _ := filepath.Rel(dir, path)
221+
packagePath := filepath.Dir(path)
222+
223+
seen := false
224+
for _, m := range moduleConfigs {
225+
if m.Name == packageName && m.Path == packagePath {
226+
seen = true
227+
break
228+
}
229+
}
230+
if !seen {
231+
moduleConfigs = append(moduleConfigs, module.Config{
232+
Name: packageName,
233+
Path: packagePath,
234+
Type: "nuget",
235+
})
236+
}
237+
}
238+
239+
return moduleConfigs, nil
240+
}

builders/ruby.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func (builder *RubyBuilder) Build(m module.Module, force bool) error {
9898
return fmt.Errorf("could not run Ruby build: %s", err.Error())
9999
}
100100

101-
bowerLogger.Debug("Done running Ruby build.")
101+
rubyLogger.Debug("Done running Ruby build.")
102102
return nil
103103
}
104104

cmd/fossa/build.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,12 @@ func doBuild(moduleConfig module.Config, allowUnresolved, force bool) error {
5858
return fmt.Errorf("could not determine whether module %s is built: %s", module.Name, err.Error())
5959
}
6060
if isBuilt && !force {
61-
return fmt.Errorf("module %s appears to already be built (use `--force` to force a rebuild)", module.Name)
62-
}
63-
64-
err = builder.Build(module, force)
65-
if err != nil {
66-
return fmt.Errorf("build failed on module %s: %s", module.Name, err.Error())
61+
buildLogger.Warningf("module %s appears to already be built (use `--force` to force a rebuild)", module.Name)
62+
} else {
63+
err = builder.Build(module, force)
64+
if err != nil {
65+
return fmt.Errorf("build failed on module %s: %s", module.Name, err.Error())
66+
}
6767
}
6868

6969
return nil

docs/how-it-works.md

+2
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ Instead of trying to guess at your build system's behavior, `fossa` runs locally
1111

1212
- JavaScript: `bower`, `npm`, `yarn`
1313
- Java/Scala: `mvn`, `gradle`, `sbt`
14+
- Python: `pip`, `setuptools`
1415
- Ruby: `bundler`
1516
- PHP: `composer`
1617
- Go: `dep`, `glide`, `godep`, `govendor`, `vndr`, `gdm`
18+
- .NET (C#, VB): `nuget`
1719
- Archives: `*.rpm`
1820

1921
## Walkthrough

docs/integrations/nuget.md

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# NuGet (.NET)
2+
3+
## Installation
4+
5+
NuGet support in FOSSA CLI depends on the following tools existing in your environment:
6+
7+
- [.NET Core CLI](https://docs.microsoft.com/en-us/dotnet/core/tools/) (defaults to `dotnet`, configure with `$DOTNET_BINARY`)
8+
- [NuGet](https://www.nuget.org/downloads) (defaults to `nuget`, configure with `$NUGET_BINARY`)
9+
10+
## Usage
11+
12+
Add a `nuget` module with the path to the folder `bower.json` in your project.
13+
14+
```yaml
15+
analyze:
16+
modules:
17+
- name: MyCompany.SomeProject.Module
18+
path: src/MyCompany.SomeProject.Module
19+
type: nuget
20+
```
21+
22+
## Design
23+
24+
### Building
25+
26+
Running `fossa build` will attempt to run `dotnet restore` on your project environment, which will automatically install the correct packages in your environment.
27+
28+
If this fails, `fossa` will then attempt to resolve your local `Packages` directory (defaulting to `{module.path}/packages`), and fall back to `nuget restore -PackagesDirectory {PACKAGE_DIR}`.
29+
30+
### Analysis
31+
32+
`fossa analyze` will first attempt to resolve any existing NuGet lockfile created by your build (at `{module.path}/project.lock.json or {module.path}/obj/project.assets.json`). It will parse these files for dependencies that were installed under the `libraries` key. If `fossa` failed to resolve a lockfile (one was not created during the build or found), `fossa` will fall back to analyzing your `packages` directory.
33+
34+
#### Known limitations
35+
36+
- Currently, `fossa` supports NuGet lockfiles of `v2` and `v3` schemas
37+
- `fossa` assumes your package directory is located at `{module.path}/packages`. If you use a global package folder or another path, we reccomend you generate a lockfile for your build.
38+
- Due to the assumptions about package installation locations, verifying whether a module is built is unreliable sans-lockfile. If you receive an inaccurate error that your build is unsatisfied, run `fossa` with the `--allow-unresolved` flag.

docs/user-guide.md

+8-2
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,15 @@ Prints analysis results to `stdout` instead of uploading results to a FOSSA serv
306306
##### `--allow-unresolved`
307307
Do not fail on unresolved dependencies.
308308

309-
For some languages, `fossa analyze` does import path tracing to determine dependencies. If these the dependencies at the import paths cannot be found, the dependency is _unresolved_.
309+
For some languages, running `fossa analyze` will result in the following error even if you've built your code:
310310

311-
Unresolved dependencies generally indicate an incomplete build or some other kind of build error. For highly custom build systems, this may not be the case.
311+
```bash
312+
CRITICAL Module {MODULE_NAME} does not appear to be built. Try first running your build or `fossa build`, and then running `fossa`.
313+
```
314+
315+
This happens when `fossa` fails to verify whether your environment has completed a build due to some kind of error. This could be due to a highly custom build process, non-conventional environment state or a misconfiguration of your build.
316+
317+
Passing `--allow-unresolved` will soften the verification standards that `fossa` runs for each language integration.
312318

313319
##### `--debug`
314320
Print debugging information to `stderr`.

module/types.go

+6-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const (
2727
SBT = Type("sbt")
2828
// Gradle is the module type for gradle.org
2929
Gradle = Type("gradle")
30+
// NuGet is the module type for nuget.org
31+
NuGet = Type("NuGet")
3032
// Pip is the module type for https://pip.pypa.io/en/stable/
3133
Pip = Type("pip")
3234

@@ -45,7 +47,7 @@ const (
4547
)
4648

4749
// Types holds the list of all available module types for analysis
48-
var Types = []Type{Bower, Composer, Maven, SBT, Gradle, Ruby, Nodejs, Golang, VendoredArchives, Pip}
50+
var Types = []Type{Bower, Composer, Maven, SBT, Gradle, NuGet, Pip, Ruby, Nodejs, Golang, VendoredArchives}
4951

5052
// Parse returns a module Type given a string
5153
func Parse(key string) (Type, error) {
@@ -107,6 +109,9 @@ func Parse(key string) (Type, error) {
107109
case "gradle":
108110
return Gradle, nil
109111

112+
case "nuget":
113+
return NuGet, nil
114+
110115
// Archive aliases
111116
case "vendoredarchives":
112117
return VendoredArchives, nil

0 commit comments

Comments
 (0)