mirror of
https://github.com/anchore/syft.git
synced 2025-11-17 16:33:21 +01:00
Merge pull request #68 from anchore/issue-31-1
Add support for file-based contents
This commit is contained in:
commit
fe338760b0
82
cmd/root.go
82
cmd/root.go
@ -17,13 +17,14 @@ import (
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: fmt.Sprintf("%s [IMAGE]", internal.ApplicationName),
|
||||
Short: "A container image BOM tool", // TODO: add copy
|
||||
Use: fmt.Sprintf("%s [SOURCE]", internal.ApplicationName),
|
||||
Short: "A tool that generates a Software Build Of Materials (SBOM)",
|
||||
Long: internal.Tprintf(`\
|
||||
Supports the following image sources:
|
||||
{{.appName}} yourrepo/yourimage:tag defaults to using images from a docker daemon
|
||||
{{.appName}} docker://yourrepo/yourimage:tag explicitly use the docker daemon
|
||||
{{.appName}} tar://path/to/yourimage.tar use a tarball from disk
|
||||
{{.appName}} dir://path/to/yourproject read directly from a path in disk
|
||||
`, map[string]interface{}{
|
||||
"appName": internal.ApplicationName,
|
||||
}),
|
||||
@ -44,38 +45,61 @@ func init() {
|
||||
)
|
||||
}
|
||||
|
||||
func startWorker(userImage string) <-chan error {
|
||||
func startWorker(userInput string) <-chan error {
|
||||
errs := make(chan error)
|
||||
go func() {
|
||||
defer close(errs)
|
||||
protocol := imgbom.NewProtocol(userInput)
|
||||
fmt.Printf("protocol: %+v", protocol)
|
||||
|
||||
log.Infof("Fetching image '%s'", userImage)
|
||||
img, err := stereoscope.GetImage(userImage)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("could not fetch image '%s': %w", userImage, err)
|
||||
return
|
||||
switch protocol.Type {
|
||||
case imgbom.DirProtocol:
|
||||
|
||||
log.Info("Cataloging directory")
|
||||
catalog, err := imgbom.CatalogDir(protocol.Value, appConfig.ScopeOpt)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("could not produce catalog: %w", err)
|
||||
}
|
||||
|
||||
bus.Publish(partybus.Event{
|
||||
Type: event.CatalogerFinished,
|
||||
Value: presenter.GetDirPresenter(appConfig.PresenterOpt, protocol.Value, catalog),
|
||||
})
|
||||
default:
|
||||
log.Infof("Fetching image '%s'", userInput)
|
||||
img, err := stereoscope.GetImage(userInput)
|
||||
|
||||
if err != nil || img == nil {
|
||||
errs <- fmt.Errorf("could not fetch image '%s': %w", userInput, err)
|
||||
|
||||
// TODO: this needs to be handled better
|
||||
bus.Publish(partybus.Event{
|
||||
Type: event.CatalogerFinished,
|
||||
Value: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer stereoscope.Cleanup()
|
||||
|
||||
log.Info("Identifying Distro")
|
||||
distro := imgbom.IdentifyDistro(img)
|
||||
if distro == nil {
|
||||
log.Errorf("error identifying distro")
|
||||
} else {
|
||||
log.Infof(" Distro: %s", distro)
|
||||
}
|
||||
|
||||
log.Info("Cataloging Image")
|
||||
catalog, err := imgbom.CatalogImg(img, appConfig.ScopeOpt)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("could not produce catalog: %w", err)
|
||||
}
|
||||
|
||||
bus.Publish(partybus.Event{
|
||||
Type: event.CatalogerFinished,
|
||||
Value: presenter.GetImgPresenter(appConfig.PresenterOpt, img, catalog),
|
||||
})
|
||||
}
|
||||
defer stereoscope.Cleanup()
|
||||
|
||||
log.Info("Identifying Distro")
|
||||
distro := imgbom.IdentifyDistro(img)
|
||||
if distro == nil {
|
||||
log.Errorf("error identifying distro")
|
||||
} else {
|
||||
log.Infof(" Distro: %s", distro)
|
||||
}
|
||||
|
||||
log.Info("Cataloging image")
|
||||
catalog, err := imgbom.CatalogImage(img, appConfig.ScopeOpt)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("could not catalog image: %w", err)
|
||||
}
|
||||
|
||||
log.Info("Complete!")
|
||||
bus.Publish(partybus.Event{
|
||||
Type: event.CatalogerFinished,
|
||||
Value: presenter.GetPresenter(appConfig.PresenterOpt, img, catalog),
|
||||
})
|
||||
}()
|
||||
return errs
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ func Catalogers() []string {
|
||||
return c
|
||||
}
|
||||
|
||||
func Catalog(s scope.Scope) (*pkg.Catalog, error) {
|
||||
func Catalog(s scope.FileContentResolver) (*pkg.Catalog, error) {
|
||||
return controllerInstance.catalog(s)
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ func (c *controller) trackCataloger() (*progress.Manual, *progress.Manual) {
|
||||
return &filesProcessed, &packagesDiscovered
|
||||
}
|
||||
|
||||
func (c *controller) catalog(s scope.Scope) (*pkg.Catalog, error) {
|
||||
func (c *controller) catalog(s scope.FileContentResolver) (*pkg.Catalog, error) {
|
||||
catalog := pkg.NewCatalog()
|
||||
fileSelection := make([]file.Reference, 0)
|
||||
|
||||
@ -79,13 +79,13 @@ func (c *controller) catalog(s scope.Scope) (*pkg.Catalog, error) {
|
||||
|
||||
// ask catalogers for files to extract from the image tar
|
||||
for _, a := range c.catalogers {
|
||||
fileSelection = append(fileSelection, a.SelectFiles(&s)...)
|
||||
fileSelection = append(fileSelection, a.SelectFiles(s)...)
|
||||
log.Debugf("cataloger '%s' selected '%d' files", a.Name(), len(fileSelection))
|
||||
filesProcessed.N += int64(len(fileSelection))
|
||||
}
|
||||
|
||||
// fetch contents for requested selection by catalogers
|
||||
contents, err := s.Image.MultipleFileContentsByRef(fileSelection...)
|
||||
contents, err := s.MultipleFileContentsByRef(fileSelection...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ func Identify(img *image.Image) *Distro {
|
||||
}
|
||||
|
||||
for path, fn := range identityFiles {
|
||||
contents, err := img.FileContentsFromSquash(path)
|
||||
contents, err := img.FileContentsFromSquash(path) // TODO: this call replaced with "MultipleFileContents"
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("unable to get contents from %s: %s", path, err)
|
||||
|
||||
@ -16,12 +16,19 @@ func IdentifyDistro(img *image.Image) *distro.Distro {
|
||||
return distro.Identify(img)
|
||||
}
|
||||
|
||||
func CatalogImage(img *image.Image, o scope.Option) (*pkg.Catalog, error) {
|
||||
s, err := scope.NewScope(img, o)
|
||||
func CatalogDir(d string, o scope.Option) (*pkg.Catalog, error) {
|
||||
s, err := scope.NewDirScope(d, o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cataloger.Catalog(s)
|
||||
}
|
||||
|
||||
func CatalogImg(img *image.Image, o scope.Option) (*pkg.Catalog, error) {
|
||||
s, err := scope.NewImageScope(img, o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cataloger.Catalog(s)
|
||||
}
|
||||
|
||||
|
||||
77
imgbom/presenter/json/dirs/presenter.go
Normal file
77
imgbom/presenter/json/dirs/presenter.go
Normal file
@ -0,0 +1,77 @@
|
||||
package dirs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/anchore/imgbom/imgbom/pkg"
|
||||
"github.com/anchore/imgbom/internal/log"
|
||||
)
|
||||
|
||||
type Presenter struct {
|
||||
catalog *pkg.Catalog
|
||||
path string
|
||||
}
|
||||
|
||||
func NewPresenter(catalog *pkg.Catalog, path string) *Presenter {
|
||||
return &Presenter{
|
||||
catalog: catalog,
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
type document struct {
|
||||
Artifacts []artifact `json:"artifacts"`
|
||||
Source string
|
||||
}
|
||||
|
||||
type source struct {
|
||||
FoundBy string `json:"foundBy"`
|
||||
Effects []string `json:"effects"`
|
||||
}
|
||||
|
||||
type artifact struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Cataloger string `json:"cataloger"`
|
||||
Sources []source `json:"sources"`
|
||||
Metadata interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
func (pres *Presenter) Present(output io.Writer) error {
|
||||
doc := document{
|
||||
Artifacts: make([]artifact, 0),
|
||||
Source: pres.path,
|
||||
}
|
||||
|
||||
// populate artifacts...
|
||||
// TODO: move this into a common package so that other text presenters can reuse
|
||||
for p := range pres.catalog.Enumerate() {
|
||||
art := artifact{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Type: p.Type.String(),
|
||||
Sources: make([]source, len(p.Source)),
|
||||
Metadata: p.Metadata,
|
||||
}
|
||||
|
||||
for idx := range p.Source {
|
||||
srcObj := source{
|
||||
FoundBy: p.FoundBy,
|
||||
Effects: []string{}, // TODO
|
||||
}
|
||||
art.Sources[idx] = srcObj
|
||||
}
|
||||
|
||||
doc.Artifacts = append(doc.Artifacts, art)
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(&doc)
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal json (presenter=json): %w", err)
|
||||
}
|
||||
|
||||
_, err = output.Write(bytes)
|
||||
return err
|
||||
}
|
||||
53
imgbom/presenter/json/dirs/presenter_test.go
Normal file
53
imgbom/presenter/json/dirs/presenter_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package dirs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"testing"
|
||||
|
||||
"github.com/anchore/go-testutils"
|
||||
"github.com/anchore/imgbom/imgbom/pkg"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
)
|
||||
|
||||
var update = flag.Bool("update", false, "update the *.golden files for json presenters")
|
||||
|
||||
func TestJsonPresenter(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
|
||||
catalog := pkg.NewCatalog()
|
||||
|
||||
// populate catalog with test data
|
||||
catalog.Add(pkg.Package{
|
||||
Name: "package-1",
|
||||
Version: "1.0.1",
|
||||
Type: pkg.DebPkg,
|
||||
})
|
||||
catalog.Add(pkg.Package{
|
||||
Name: "package-2",
|
||||
Version: "2.0.1",
|
||||
Type: pkg.DebPkg,
|
||||
})
|
||||
|
||||
pres := NewPresenter(catalog, "/some/path")
|
||||
|
||||
// run presenter
|
||||
err := pres.Present(&buffer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := buffer.Bytes()
|
||||
|
||||
if *update {
|
||||
testutils.UpdateGoldenFileContents(t, actual)
|
||||
}
|
||||
|
||||
var expected = testutils.GetGoldenFileContents(t)
|
||||
|
||||
if !bytes.Equal(expected, actual) {
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(string(actual), string(expected), true)
|
||||
t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
{"artifacts":[{"name":"package-1","version":"1.0.1","type":"deb","cataloger":"","sources":[],"metadata":null},{"name":"package-2","version":"2.0.1","type":"deb","cataloger":"","sources":[],"metadata":null}],"Source":"/some/path"}
|
||||
@ -1,7 +1,8 @@
|
||||
package json
|
||||
package imgs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/anchore/imgbom/imgbom/pkg"
|
||||
@ -82,6 +83,7 @@ func (pres *Presenter) Present(output io.Writer) error {
|
||||
}
|
||||
|
||||
// populate artifacts...
|
||||
// TODO: move this into a common package so that other text presenters can reuse
|
||||
for p := range pres.catalog.Enumerate() {
|
||||
art := artifact{
|
||||
Name: p.Name,
|
||||
@ -93,14 +95,17 @@ func (pres *Presenter) Present(output io.Writer) error {
|
||||
|
||||
for idx, src := range p.Source {
|
||||
fileMetadata, err := pres.img.FileCatalog.Get(src)
|
||||
var layer int
|
||||
if err != nil {
|
||||
// TODO: test case
|
||||
log.Errorf("could not get metadata from catalog (presenter=json): %+v", src)
|
||||
return fmt.Errorf("could not get metadata from catalog (presenter=json src=%v): %w", src, err)
|
||||
}
|
||||
|
||||
layer = int(fileMetadata.Source.Metadata.Index)
|
||||
|
||||
srcObj := source{
|
||||
FoundBy: p.FoundBy,
|
||||
Layer: int(fileMetadata.Source.Metadata.Index),
|
||||
Layer: layer,
|
||||
Effects: []string{}, // TODO
|
||||
}
|
||||
art.Sources[idx] = srcObj
|
||||
@ -1,4 +1,4 @@
|
||||
package json
|
||||
package imgs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@ -4,8 +4,10 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/anchore/imgbom/imgbom/pkg"
|
||||
"github.com/anchore/imgbom/imgbom/presenter/json"
|
||||
"github.com/anchore/imgbom/imgbom/presenter/text"
|
||||
json_dirs "github.com/anchore/imgbom/imgbom/presenter/json/dirs"
|
||||
json_imgs "github.com/anchore/imgbom/imgbom/presenter/json/imgs"
|
||||
text_dirs "github.com/anchore/imgbom/imgbom/presenter/text/dirs"
|
||||
text_imgs "github.com/anchore/imgbom/imgbom/presenter/text/imgs"
|
||||
"github.com/anchore/stereoscope/pkg/image"
|
||||
)
|
||||
|
||||
@ -13,13 +15,23 @@ type Presenter interface {
|
||||
Present(io.Writer) error
|
||||
}
|
||||
|
||||
func GetPresenter(option Option, img *image.Image, catalog *pkg.Catalog) Presenter {
|
||||
func GetImgPresenter(option Option, img *image.Image, catalog *pkg.Catalog) Presenter {
|
||||
switch option {
|
||||
case JSONPresenter:
|
||||
return json.NewPresenter(img, catalog)
|
||||
return json_imgs.NewPresenter(img, catalog)
|
||||
case TextPresenter:
|
||||
return text.NewPresenter(img, catalog)
|
||||
|
||||
return text_imgs.NewPresenter(img, catalog)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func GetDirPresenter(option Option, path string, catalog *pkg.Catalog) Presenter {
|
||||
switch option {
|
||||
case JSONPresenter:
|
||||
return json_dirs.NewPresenter(catalog, path)
|
||||
case TextPresenter:
|
||||
return text_dirs.NewPresenter(catalog, path)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
45
imgbom/presenter/text/dirs/presenter.go
Normal file
45
imgbom/presenter/text/dirs/presenter.go
Normal file
@ -0,0 +1,45 @@
|
||||
package text
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/anchore/imgbom/imgbom/pkg"
|
||||
)
|
||||
|
||||
type Presenter struct {
|
||||
catalog *pkg.Catalog
|
||||
path string
|
||||
}
|
||||
|
||||
func NewPresenter(catalog *pkg.Catalog, path string) *Presenter {
|
||||
return &Presenter{
|
||||
catalog: catalog,
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
// Present is a method that is in charge of writing to an output buffer
|
||||
func (pres *Presenter) Present(output io.Writer) error {
|
||||
// init the tabular writer
|
||||
w := new(tabwriter.Writer)
|
||||
w.Init(output, 0, 8, 0, '\t', tabwriter.AlignRight)
|
||||
fmt.Fprintln(w, fmt.Sprintf("[Path: %s]", pres.path))
|
||||
|
||||
// populate artifacts...
|
||||
// TODO: move this into a common package so that other text presenters can reuse
|
||||
for p := range pres.catalog.Enumerate() {
|
||||
fmt.Fprintln(w, fmt.Sprintf("[%s]", p.Name))
|
||||
fmt.Fprintln(w, " Version:\t", p.Version)
|
||||
fmt.Fprintln(w, " Type:\t", p.Type.String())
|
||||
if p.Metadata != nil {
|
||||
fmt.Fprintf(w, " Metadata:\t%+v\n", p.Metadata)
|
||||
}
|
||||
fmt.Fprintln(w, " Found by:\t", p.FoundBy)
|
||||
fmt.Fprintln(w)
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
53
imgbom/presenter/text/dirs/presenter_test.go
Normal file
53
imgbom/presenter/text/dirs/presenter_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package text
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"testing"
|
||||
|
||||
"github.com/anchore/go-testutils"
|
||||
"github.com/anchore/imgbom/imgbom/pkg"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
)
|
||||
|
||||
var update = flag.Bool("update", false, "update the *.golden files for json presenters")
|
||||
|
||||
func TestTextPresenter(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
|
||||
catalog := pkg.NewCatalog()
|
||||
|
||||
// populate catalog with test data
|
||||
catalog.Add(pkg.Package{
|
||||
Name: "package-1",
|
||||
Version: "1.0.1",
|
||||
Type: pkg.DebPkg,
|
||||
})
|
||||
catalog.Add(pkg.Package{
|
||||
Name: "package-2",
|
||||
Version: "2.0.1",
|
||||
Type: pkg.DebPkg,
|
||||
})
|
||||
|
||||
pres := NewPresenter(catalog, "/some/path")
|
||||
|
||||
// run presenter
|
||||
err := pres.Present(&buffer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := buffer.Bytes()
|
||||
|
||||
if *update {
|
||||
testutils.UpdateGoldenFileContents(t, actual)
|
||||
}
|
||||
|
||||
var expected = testutils.GetGoldenFileContents(t)
|
||||
|
||||
if !bytes.Equal(expected, actual) {
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(string(actual), string(expected), true)
|
||||
t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
{"artifacts":[{"name":"package-1","version":"1.0.1","type":"deb","cataloger":"","sources":[],"metadata":null},{"name":"package-2","version":"2.0.1","type":"deb","cataloger":"","sources":[],"metadata":null}],"Source":"/some/path"}
|
||||
@ -0,0 +1,11 @@
|
||||
[Path: /some/path]
|
||||
[package-1]
|
||||
Version: 1.0.1
|
||||
Type: deb
|
||||
Found by:
|
||||
|
||||
[package-2]
|
||||
Version: 2.0.1
|
||||
Type: deb
|
||||
Found by:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package text
|
||||
package imgs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@ -9,13 +9,11 @@ import (
|
||||
stereoscopeImg "github.com/anchore/stereoscope/pkg/image"
|
||||
)
|
||||
|
||||
// Presenter holds the Present method to produce output
|
||||
type Presenter struct {
|
||||
img *stereoscopeImg.Image
|
||||
catalog *pkg.Catalog
|
||||
}
|
||||
|
||||
// NewPresenter is a constructor for a Presenter
|
||||
func NewPresenter(img *stereoscopeImg.Image, catalog *pkg.Catalog) *Presenter {
|
||||
return &Presenter{
|
||||
img: img,
|
||||
@ -46,6 +44,7 @@ func (pres *Presenter) Present(output io.Writer) error {
|
||||
}
|
||||
|
||||
// populate artifacts...
|
||||
// TODO: move this into a common package so that other text presenters can reuse
|
||||
for p := range pres.catalog.Enumerate() {
|
||||
fmt.Fprintln(w, fmt.Sprintf("[%s]", p.Name))
|
||||
fmt.Fprintln(w, " Version:\t", p.Version)
|
||||
@ -1,4 +1,4 @@
|
||||
package text
|
||||
package imgs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
58
imgbom/protocol.go
Normal file
58
imgbom/protocol.go
Normal file
@ -0,0 +1,58 @@
|
||||
package imgbom
|
||||
|
||||
import "strings"
|
||||
|
||||
// Potentially consider moving this out into a generic package that parses user input.
|
||||
// Aside from scope, this is the 2nd package that looks at a string to parse the input
|
||||
// and return an Option type.
|
||||
|
||||
const (
|
||||
UnknownProtocol ProtocolType = iota
|
||||
ImageProtocol
|
||||
DirProtocol
|
||||
)
|
||||
|
||||
var optionStr = []string{
|
||||
"UnknownProtocol",
|
||||
"image",
|
||||
"dir",
|
||||
}
|
||||
|
||||
type ProtocolType int
|
||||
|
||||
type Protocol struct {
|
||||
Type ProtocolType
|
||||
Value string
|
||||
}
|
||||
|
||||
func NewProtocol(userStr string) Protocol {
|
||||
candidates := strings.Split(userStr, "://")
|
||||
|
||||
switch len(candidates) {
|
||||
case 2:
|
||||
if strings.HasPrefix(userStr, "dir://") {
|
||||
return Protocol{
|
||||
Type: DirProtocol,
|
||||
Value: strings.TrimPrefix(userStr, "dir://"),
|
||||
}
|
||||
}
|
||||
// default to an Image for anything else since stereoscope can handle this
|
||||
return Protocol{
|
||||
Type: ImageProtocol,
|
||||
Value: userStr,
|
||||
}
|
||||
default:
|
||||
return Protocol{
|
||||
Type: ImageProtocol,
|
||||
Value: userStr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o ProtocolType) String() string {
|
||||
if int(o) >= len(optionStr) || o < 0 {
|
||||
return optionStr[0]
|
||||
}
|
||||
|
||||
return optionStr[o]
|
||||
}
|
||||
43
imgbom/protocol_test.go
Normal file
43
imgbom/protocol_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
package imgbom
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewProtocol(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
input string
|
||||
expType ProtocolType
|
||||
expValue string
|
||||
}{
|
||||
{
|
||||
desc: "directory protocol",
|
||||
input: "dir:///opt/",
|
||||
expType: DirProtocol,
|
||||
expValue: "/opt/",
|
||||
},
|
||||
{
|
||||
desc: "unknown protocol",
|
||||
input: "s4:///opt/",
|
||||
expType: ImageProtocol,
|
||||
expValue: "s4:///opt/",
|
||||
},
|
||||
{
|
||||
desc: "docker protocol",
|
||||
input: "docker://ubuntu:20.04",
|
||||
expType: ImageProtocol,
|
||||
expValue: "docker://ubuntu:20.04",
|
||||
},
|
||||
}
|
||||
for _, test := range testCases {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
p := NewProtocol(test.input)
|
||||
if p.Type != test.expType {
|
||||
t.Errorf("mismatched type in protocol: '%v' != '%v'", p.Type, test.expType)
|
||||
}
|
||||
if p.Value != test.expValue {
|
||||
t.Errorf("mismatched protocol value: '%s' != '%s'", p.Value, test.expValue)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,15 @@ import (
|
||||
"github.com/anchore/stereoscope/pkg/image"
|
||||
)
|
||||
|
||||
type FileContentResolver interface {
|
||||
ContentResolver
|
||||
FileResolver
|
||||
}
|
||||
|
||||
type ContentResolver interface {
|
||||
MultipleFileContentsByRef(f ...file.Reference) (map[file.Reference]string, error)
|
||||
}
|
||||
|
||||
type FileResolver interface {
|
||||
FilesByPath(paths ...file.Path) ([]file.Reference, error)
|
||||
FilesByGlob(patterns ...string) ([]file.Reference, error)
|
||||
|
||||
@ -2,38 +2,127 @@ package scope
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/anchore/imgbom/internal/log"
|
||||
"github.com/anchore/stereoscope/pkg/file"
|
||||
"github.com/anchore/stereoscope/pkg/image"
|
||||
)
|
||||
|
||||
type Scope struct {
|
||||
type DirectoryScope struct {
|
||||
Option Option
|
||||
Path string
|
||||
}
|
||||
|
||||
func (s DirectoryScope) String() string {
|
||||
return fmt.Sprintf("dir://%s", s.Path)
|
||||
}
|
||||
|
||||
func (s DirectoryScope) FilesByPath(userPaths ...file.Path) ([]file.Reference, error) {
|
||||
var references = make([]file.Reference, 0)
|
||||
|
||||
for _, userPath := range userPaths {
|
||||
resolvedPath := path.Join(s.Path, string(userPath))
|
||||
_, err := os.Stat(resolvedPath)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
log.Errorf("path (%s) is not valid: %v", resolvedPath, err)
|
||||
}
|
||||
filePath := file.Path(resolvedPath)
|
||||
references = append(references, file.NewFileReference(filePath))
|
||||
}
|
||||
|
||||
return references, nil
|
||||
}
|
||||
|
||||
func fileContents(path file.Path) ([]byte, error) {
|
||||
contents, err := ioutil.ReadFile(string(path))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func (s DirectoryScope) FilesByGlob(patterns ...string) ([]file.Reference, error) {
|
||||
result := make([]file.Reference, 0)
|
||||
|
||||
for _, pattern := range patterns {
|
||||
pathPattern := path.Join(s.Path, pattern)
|
||||
matches, err := filepath.Glob(pathPattern)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, match := range matches {
|
||||
fileMeta, err := os.Stat(match)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fileMeta.IsDir() {
|
||||
continue
|
||||
}
|
||||
matchedPath := file.Path(match)
|
||||
result = append(result, file.NewFileReference(matchedPath))
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s DirectoryScope) MultipleFileContentsByRef(f ...file.Reference) (map[file.Reference]string, error) {
|
||||
refContents := make(map[file.Reference]string)
|
||||
for _, fileRef := range f {
|
||||
contents, err := fileContents(fileRef.Path)
|
||||
if err != nil {
|
||||
return refContents, fmt.Errorf("could not read contents of file: %s", fileRef.Path)
|
||||
}
|
||||
refContents[fileRef] = string(contents)
|
||||
}
|
||||
return refContents, nil
|
||||
}
|
||||
|
||||
type ImageScope struct {
|
||||
Option Option
|
||||
resolver FileResolver
|
||||
Image *image.Image
|
||||
}
|
||||
|
||||
func NewScope(img *image.Image, option Option) (Scope, error) {
|
||||
func NewDirScope(path string, option Option) (DirectoryScope, error) {
|
||||
return DirectoryScope{
|
||||
Option: option,
|
||||
Path: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewImageScope(img *image.Image, option Option) (ImageScope, error) {
|
||||
if img == nil {
|
||||
return Scope{}, fmt.Errorf("no image given")
|
||||
return ImageScope{}, fmt.Errorf("no image given")
|
||||
}
|
||||
|
||||
resolver, err := getFileResolver(img, option)
|
||||
if err != nil {
|
||||
return Scope{}, fmt.Errorf("could not determine file resolver: %w", err)
|
||||
return ImageScope{}, fmt.Errorf("could not determine file resolver: %w", err)
|
||||
}
|
||||
|
||||
return Scope{
|
||||
return ImageScope{
|
||||
Option: option,
|
||||
resolver: resolver,
|
||||
Image: img,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Scope) FilesByPath(paths ...file.Path) ([]file.Reference, error) {
|
||||
func (s ImageScope) FilesByPath(paths ...file.Path) ([]file.Reference, error) {
|
||||
return s.resolver.FilesByPath(paths...)
|
||||
}
|
||||
|
||||
func (s Scope) FilesByGlob(patterns ...string) ([]file.Reference, error) {
|
||||
func (s ImageScope) FilesByGlob(patterns ...string) ([]file.Reference, error) {
|
||||
return s.resolver.FilesByGlob(patterns...)
|
||||
}
|
||||
|
||||
func (s ImageScope) MultipleFileContentsByRef(f ...file.Reference) (map[file.Reference]string, error) {
|
||||
return s.Image.MultipleFileContentsByRef(f...)
|
||||
}
|
||||
|
||||
147
imgbom/scope/scope_test.go
Normal file
147
imgbom/scope/scope_test.go
Normal file
@ -0,0 +1,147 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/anchore/stereoscope/pkg/file"
|
||||
)
|
||||
|
||||
func TestDirectoryScope(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
input string
|
||||
expString string
|
||||
inputPaths []file.Path
|
||||
expRefs int
|
||||
}{
|
||||
{
|
||||
desc: "no paths exist",
|
||||
input: "foobar/",
|
||||
expString: "dir://foobar/",
|
||||
inputPaths: []file.Path{file.Path("/opt/"), file.Path("/other")},
|
||||
expRefs: 0,
|
||||
},
|
||||
{
|
||||
desc: "path detected",
|
||||
input: "test-fixtures",
|
||||
expString: "dir://test-fixtures",
|
||||
inputPaths: []file.Path{file.Path("path-detected")},
|
||||
expRefs: 1,
|
||||
},
|
||||
{
|
||||
desc: "no files-by-path detected",
|
||||
input: "test-fixtures",
|
||||
expString: "dir://test-fixtures",
|
||||
inputPaths: []file.Path{file.Path("no-path-detected")},
|
||||
expRefs: 0,
|
||||
},
|
||||
}
|
||||
for _, test := range testCases {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
p, err := NewDirScope(test.input, AllLayersScope)
|
||||
if err != nil {
|
||||
t.Errorf("could not create NewDirScope: %w", err)
|
||||
}
|
||||
if p.String() != test.expString {
|
||||
t.Errorf("mismatched stringer: '%s' != '%s'", p.String(), test.expString)
|
||||
}
|
||||
|
||||
refs, err := p.FilesByPath(test.inputPaths...)
|
||||
if err != nil {
|
||||
t.Errorf("FilesByPath call produced an error: %w", err)
|
||||
}
|
||||
if len(refs) != test.expRefs {
|
||||
t.Errorf("unexpected number of refs returned: %d != %d", len(refs), test.expRefs)
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleFileContentsByRef(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
input string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "test-fixtures/path-detected",
|
||||
desc: "empty file",
|
||||
path: "empty",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
input: "test-fixtures/path-detected",
|
||||
desc: "path does not exist",
|
||||
path: "foo",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
input: "test-fixtures/path-detected",
|
||||
desc: "file has contents",
|
||||
path: "test-fixtures/path-detected/.vimrc",
|
||||
expected: "\" A .vimrc file\n",
|
||||
},
|
||||
}
|
||||
for _, test := range testCases {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
p, err := NewDirScope(test.input, AllLayersScope)
|
||||
if err != nil {
|
||||
t.Errorf("could not create NewDirScope: %w", err)
|
||||
}
|
||||
ref := file.NewFileReference(file.Path(test.path))
|
||||
contents, err := p.MultipleFileContentsByRef(ref)
|
||||
content := contents[ref]
|
||||
|
||||
if content != test.expected {
|
||||
t.Errorf("unexpected contents from file: '%s' != '%s'", content, test.expected)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesByGlob(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
input string
|
||||
glob string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
input: "test-fixtures",
|
||||
desc: "no matches",
|
||||
glob: "bar/foo",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
input: "test-fixtures/path-detected",
|
||||
desc: "a single match",
|
||||
glob: "*vimrc",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
input: "test-fixtures/path-detected",
|
||||
desc: "multiple matches",
|
||||
glob: "*",
|
||||
expected: 2,
|
||||
},
|
||||
}
|
||||
for _, test := range testCases {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
p, err := NewDirScope(test.input, AllLayersScope)
|
||||
if err != nil {
|
||||
t.Errorf("could not create NewDirScope: %w", err)
|
||||
}
|
||||
|
||||
contents, err := p.FilesByGlob(test.glob)
|
||||
|
||||
if len(contents) != test.expected {
|
||||
t.Errorf("unexpected number of files found by glob (%s): %d != %d", test.glob, len(contents), test.expected)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
1
imgbom/scope/test-fixtures/path-detected/.vimrc
Normal file
1
imgbom/scope/test-fixtures/path-detected/.vimrc
Normal file
@ -0,0 +1 @@
|
||||
" A .vimrc file
|
||||
0
imgbom/scope/test-fixtures/path-detected/empty
Normal file
0
imgbom/scope/test-fixtures/path-detected/empty
Normal file
77
integration/dir_presenters_test.go
Normal file
77
integration/dir_presenters_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
// +build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"testing"
|
||||
|
||||
"github.com/anchore/go-testutils"
|
||||
"github.com/anchore/imgbom/imgbom"
|
||||
"github.com/anchore/imgbom/imgbom/presenter"
|
||||
"github.com/anchore/imgbom/imgbom/scope"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
)
|
||||
|
||||
var update = flag.Bool("update", false, "update the *.golden files for json presenters")
|
||||
|
||||
func TestDirTextPresenter(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
protocol := imgbom.NewProtocol("dir://test-fixtures")
|
||||
if protocol.Type != imgbom.DirProtocol {
|
||||
t.Errorf("unexpected protocol returned: %v != %v", protocol.Type, imgbom.DirProtocol)
|
||||
}
|
||||
|
||||
catalog, err := imgbom.CatalogDir(protocol.Value, scope.AllLayersScope)
|
||||
if err != nil {
|
||||
t.Errorf("could not produce catalog: %w", err)
|
||||
}
|
||||
presenterOpt := presenter.ParseOption("text")
|
||||
dirPresenter := presenter.GetDirPresenter(presenterOpt, protocol.Value, catalog)
|
||||
|
||||
dirPresenter.Present(&buffer)
|
||||
actual := buffer.Bytes()
|
||||
if *update {
|
||||
testutils.UpdateGoldenFileContents(t, actual)
|
||||
}
|
||||
|
||||
var expected = testutils.GetGoldenFileContents(t)
|
||||
|
||||
if !bytes.Equal(expected, actual) {
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(string(actual), string(expected), true)
|
||||
t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestDirJsonPresenter(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
protocol := imgbom.NewProtocol("dir://test-fixtures")
|
||||
if protocol.Type != imgbom.DirProtocol {
|
||||
t.Errorf("unexpected protocol returned: %v != %v", protocol.Type, imgbom.DirProtocol)
|
||||
}
|
||||
|
||||
catalog, err := imgbom.CatalogDir(protocol.Value, scope.AllLayersScope)
|
||||
if err != nil {
|
||||
t.Errorf("could not produce catalog: %w", err)
|
||||
}
|
||||
presenterOpt := presenter.ParseOption("json")
|
||||
dirPresenter := presenter.GetDirPresenter(presenterOpt, protocol.Value, catalog)
|
||||
|
||||
dirPresenter.Present(&buffer)
|
||||
actual := buffer.Bytes()
|
||||
if *update {
|
||||
testutils.UpdateGoldenFileContents(t, actual)
|
||||
}
|
||||
|
||||
var expected = testutils.GetGoldenFileContents(t)
|
||||
|
||||
if !bytes.Equal(expected, actual) {
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(string(actual), string(expected), true)
|
||||
t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
|
||||
}
|
||||
|
||||
}
|
||||
@ -6,7 +6,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/anchore/go-testutils"
|
||||
"github.com/anchore/imgbom/imgbom"
|
||||
"github.com/anchore/imgbom/imgbom/cataloger"
|
||||
"github.com/anchore/imgbom/imgbom/pkg"
|
||||
"github.com/anchore/imgbom/imgbom/scope"
|
||||
@ -16,7 +15,8 @@ func TestLanguageImage(t *testing.T) {
|
||||
img, cleanup := testutils.GetFixtureImage(t, "docker-archive", "image-language-pkgs")
|
||||
defer cleanup()
|
||||
|
||||
catalog, err := imgbom.CatalogImage(img, scope.AllLayersScope)
|
||||
s, err := scope.NewImageScope(img, scope.AllLayersScope)
|
||||
catalog, err := cataloger.Catalog(s)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to catalog image: %+v", err)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
256
integration/test-fixtures/snapshot/TestDirTextPresenter.golden
Normal file
256
integration/test-fixtures/snapshot/TestDirTextPresenter.golden
Normal file
@ -0,0 +1,256 @@
|
||||
[Path: test-fixtures]
|
||||
[actionmailer]
|
||||
Version: 4.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[actionpack]
|
||||
Version: 4.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[actionview]
|
||||
Version: 4.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[activemodel]
|
||||
Version: 4.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[activerecord]
|
||||
Version: 4.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[activesupport]
|
||||
Version: 4.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[arel]
|
||||
Version: 5.0.1.20140414130214
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[bootstrap-sass]
|
||||
Version: 3.1.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[builder]
|
||||
Version: 3.2.2
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[coffee-rails]
|
||||
Version: 4.0.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[coffee-script]
|
||||
Version: 2.2.0
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[coffee-script-source]
|
||||
Version: 1.7.0
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[erubis]
|
||||
Version: 2.7.0
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[execjs]
|
||||
Version: 2.0.2
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[hike]
|
||||
Version: 1.2.3
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[i18n]
|
||||
Version: 0.6.9
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[jbuilder]
|
||||
Version: 2.0.7
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[jquery-rails]
|
||||
Version: 3.1.0
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[json]
|
||||
Version: 1.8.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[kgio]
|
||||
Version: 2.9.2
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[libv8]
|
||||
Version: 3.16.14.3
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[mail]
|
||||
Version: 2.5.4
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[mime-types]
|
||||
Version: 1.25.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[minitest]
|
||||
Version: 5.3.4
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[multi_json]
|
||||
Version: 1.10.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[mysql2]
|
||||
Version: 0.3.16
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[polyglot]
|
||||
Version: 0.3.4
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[rack]
|
||||
Version: 1.5.2
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[rack-test]
|
||||
Version: 0.6.2
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[rails]
|
||||
Version: 4.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[railties]
|
||||
Version: 4.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[raindrops]
|
||||
Version: 0.13.0
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[rake]
|
||||
Version: 10.3.2
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[rdoc]
|
||||
Version: 4.1.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[ref]
|
||||
Version: 1.0.5
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[sass]
|
||||
Version: 3.2.19
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[sass-rails]
|
||||
Version: 4.0.3
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[sdoc]
|
||||
Version: 0.4.0
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[spring]
|
||||
Version: 1.1.3
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[sprockets]
|
||||
Version: 2.11.0
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[sprockets-rails]
|
||||
Version: 2.1.3
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[sqlite3]
|
||||
Version: 1.3.9
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[therubyracer]
|
||||
Version: 0.12.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[thor]
|
||||
Version: 0.19.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[thread_safe]
|
||||
Version: 0.3.3
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[tilt]
|
||||
Version: 1.4.1
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[treetop]
|
||||
Version: 1.4.15
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[turbolinks]
|
||||
Version: 2.2.2
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[tzinfo]
|
||||
Version: 1.2.0
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[uglifier]
|
||||
Version: 2.5.0
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
[unicorn]
|
||||
Version: 4.8.3
|
||||
Type: bundle
|
||||
Found by: bundler-cataloger
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user