diff --git a/cmd/root.go b/cmd/root.go index 94534dbe6..b5acd90a4 100644 --- a/cmd/root.go +++ b/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 } diff --git a/imgbom/cataloger/controller.go b/imgbom/cataloger/controller.go index cf4197453..294a13b29 100644 --- a/imgbom/cataloger/controller.go +++ b/imgbom/cataloger/controller.go @@ -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 } diff --git a/imgbom/distro/identify.go b/imgbom/distro/identify.go index 3249c01cf..ccabd0819 100644 --- a/imgbom/distro/identify.go +++ b/imgbom/distro/identify.go @@ -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) diff --git a/imgbom/lib.go b/imgbom/lib.go index d0368a2f5..c0e6a4b20 100644 --- a/imgbom/lib.go +++ b/imgbom/lib.go @@ -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) } diff --git a/imgbom/presenter/json/dirs/presenter.go b/imgbom/presenter/json/dirs/presenter.go new file mode 100644 index 000000000..25e4d15a6 --- /dev/null +++ b/imgbom/presenter/json/dirs/presenter.go @@ -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 +} diff --git a/imgbom/presenter/json/dirs/presenter_test.go b/imgbom/presenter/json/dirs/presenter_test.go new file mode 100644 index 000000000..fbb22564d --- /dev/null +++ b/imgbom/presenter/json/dirs/presenter_test.go @@ -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)) + } + +} diff --git a/imgbom/presenter/json/dirs/test-fixtures/snapshot/TestJsonPresenter.golden b/imgbom/presenter/json/dirs/test-fixtures/snapshot/TestJsonPresenter.golden new file mode 100644 index 000000000..ab2fd8317 --- /dev/null +++ b/imgbom/presenter/json/dirs/test-fixtures/snapshot/TestJsonPresenter.golden @@ -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"} \ No newline at end of file diff --git a/imgbom/presenter/json/presenter.go b/imgbom/presenter/json/imgs/presenter.go similarity index 89% rename from imgbom/presenter/json/presenter.go rename to imgbom/presenter/json/imgs/presenter.go index 995b36f98..7efaada71 100644 --- a/imgbom/presenter/json/presenter.go +++ b/imgbom/presenter/json/imgs/presenter.go @@ -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 diff --git a/imgbom/presenter/json/presenter_test.go b/imgbom/presenter/json/imgs/presenter_test.go similarity index 99% rename from imgbom/presenter/json/presenter_test.go rename to imgbom/presenter/json/imgs/presenter_test.go index cb04a33ac..1f174bb1f 100644 --- a/imgbom/presenter/json/presenter_test.go +++ b/imgbom/presenter/json/imgs/presenter_test.go @@ -1,4 +1,4 @@ -package json +package imgs import ( "bytes" diff --git a/imgbom/presenter/json/test-fixtures/image-simple/Dockerfile b/imgbom/presenter/json/imgs/test-fixtures/image-simple/Dockerfile similarity index 100% rename from imgbom/presenter/json/test-fixtures/image-simple/Dockerfile rename to imgbom/presenter/json/imgs/test-fixtures/image-simple/Dockerfile diff --git a/imgbom/presenter/json/test-fixtures/image-simple/file-1.txt b/imgbom/presenter/json/imgs/test-fixtures/image-simple/file-1.txt similarity index 100% rename from imgbom/presenter/json/test-fixtures/image-simple/file-1.txt rename to imgbom/presenter/json/imgs/test-fixtures/image-simple/file-1.txt diff --git a/imgbom/presenter/json/test-fixtures/image-simple/file-2.txt b/imgbom/presenter/json/imgs/test-fixtures/image-simple/file-2.txt similarity index 100% rename from imgbom/presenter/json/test-fixtures/image-simple/file-2.txt rename to imgbom/presenter/json/imgs/test-fixtures/image-simple/file-2.txt diff --git a/imgbom/presenter/json/test-fixtures/image-simple/target/really/nested/file-3.txt b/imgbom/presenter/json/imgs/test-fixtures/image-simple/target/really/nested/file-3.txt similarity index 100% rename from imgbom/presenter/json/test-fixtures/image-simple/target/really/nested/file-3.txt rename to imgbom/presenter/json/imgs/test-fixtures/image-simple/target/really/nested/file-3.txt diff --git a/imgbom/presenter/json/test-fixtures/snapshot/TestJsonPresenter.golden b/imgbom/presenter/json/imgs/test-fixtures/snapshot/TestJsonPresenter.golden similarity index 100% rename from imgbom/presenter/json/test-fixtures/snapshot/TestJsonPresenter.golden rename to imgbom/presenter/json/imgs/test-fixtures/snapshot/TestJsonPresenter.golden diff --git a/imgbom/presenter/json/test-fixtures/snapshot/anchore-fixture-image-simple.golden b/imgbom/presenter/json/imgs/test-fixtures/snapshot/anchore-fixture-image-simple.golden similarity index 100% rename from imgbom/presenter/json/test-fixtures/snapshot/anchore-fixture-image-simple.golden rename to imgbom/presenter/json/imgs/test-fixtures/snapshot/anchore-fixture-image-simple.golden diff --git a/imgbom/presenter/presenter.go b/imgbom/presenter/presenter.go index 6a51c5e74..5170c6d57 100644 --- a/imgbom/presenter/presenter.go +++ b/imgbom/presenter/presenter.go @@ -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 } diff --git a/imgbom/presenter/text/dirs/presenter.go b/imgbom/presenter/text/dirs/presenter.go new file mode 100644 index 000000000..8040b337c --- /dev/null +++ b/imgbom/presenter/text/dirs/presenter.go @@ -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 +} diff --git a/imgbom/presenter/text/dirs/presenter_test.go b/imgbom/presenter/text/dirs/presenter_test.go new file mode 100644 index 000000000..20e9a62f8 --- /dev/null +++ b/imgbom/presenter/text/dirs/presenter_test.go @@ -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)) + } + +} diff --git a/imgbom/presenter/text/dirs/test-fixtures/snapshot/TestJsonPresenter.golden b/imgbom/presenter/text/dirs/test-fixtures/snapshot/TestJsonPresenter.golden new file mode 100644 index 000000000..ab2fd8317 --- /dev/null +++ b/imgbom/presenter/text/dirs/test-fixtures/snapshot/TestJsonPresenter.golden @@ -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"} \ No newline at end of file diff --git a/imgbom/presenter/text/dirs/test-fixtures/snapshot/TestTextPresenter.golden b/imgbom/presenter/text/dirs/test-fixtures/snapshot/TestTextPresenter.golden new file mode 100644 index 000000000..f41bf461d --- /dev/null +++ b/imgbom/presenter/text/dirs/test-fixtures/snapshot/TestTextPresenter.golden @@ -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: + diff --git a/imgbom/presenter/text/presenter.go b/imgbom/presenter/text/imgs/presenter.go similarity index 92% rename from imgbom/presenter/text/presenter.go rename to imgbom/presenter/text/imgs/presenter.go index ef7531105..6a51b2186 100644 --- a/imgbom/presenter/text/presenter.go +++ b/imgbom/presenter/text/imgs/presenter.go @@ -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) diff --git a/imgbom/presenter/text/presenter_test.go b/imgbom/presenter/text/imgs/presenter_test.go similarity index 99% rename from imgbom/presenter/text/presenter_test.go rename to imgbom/presenter/text/imgs/presenter_test.go index 9b3feaeab..ef0b0e75a 100644 --- a/imgbom/presenter/text/presenter_test.go +++ b/imgbom/presenter/text/imgs/presenter_test.go @@ -1,4 +1,4 @@ -package text +package imgs import ( "bytes" diff --git a/imgbom/presenter/text/test-fixtures/image-simple/Dockerfile b/imgbom/presenter/text/imgs/test-fixtures/image-simple/Dockerfile similarity index 100% rename from imgbom/presenter/text/test-fixtures/image-simple/Dockerfile rename to imgbom/presenter/text/imgs/test-fixtures/image-simple/Dockerfile diff --git a/imgbom/presenter/text/test-fixtures/image-simple/file-1.txt b/imgbom/presenter/text/imgs/test-fixtures/image-simple/file-1.txt similarity index 100% rename from imgbom/presenter/text/test-fixtures/image-simple/file-1.txt rename to imgbom/presenter/text/imgs/test-fixtures/image-simple/file-1.txt diff --git a/imgbom/presenter/text/test-fixtures/image-simple/file-2.txt b/imgbom/presenter/text/imgs/test-fixtures/image-simple/file-2.txt similarity index 100% rename from imgbom/presenter/text/test-fixtures/image-simple/file-2.txt rename to imgbom/presenter/text/imgs/test-fixtures/image-simple/file-2.txt diff --git a/imgbom/presenter/text/test-fixtures/snapshot/TestTextPresenter.golden b/imgbom/presenter/text/imgs/test-fixtures/snapshot/TestTextPresenter.golden similarity index 100% rename from imgbom/presenter/text/test-fixtures/snapshot/TestTextPresenter.golden rename to imgbom/presenter/text/imgs/test-fixtures/snapshot/TestTextPresenter.golden diff --git a/imgbom/protocol.go b/imgbom/protocol.go new file mode 100644 index 000000000..41b67258d --- /dev/null +++ b/imgbom/protocol.go @@ -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] +} diff --git a/imgbom/protocol_test.go b/imgbom/protocol_test.go new file mode 100644 index 000000000..c76f07db8 --- /dev/null +++ b/imgbom/protocol_test.go @@ -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) + } + + }) + } +} diff --git a/imgbom/scope/file_resolver.go b/imgbom/scope/file_resolver.go index 713fc28c4..7d8484de6 100644 --- a/imgbom/scope/file_resolver.go +++ b/imgbom/scope/file_resolver.go @@ -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) diff --git a/imgbom/scope/scope.go b/imgbom/scope/scope.go index 623c9b77f..1dba71a5f 100644 --- a/imgbom/scope/scope.go +++ b/imgbom/scope/scope.go @@ -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...) +} diff --git a/imgbom/scope/scope_test.go b/imgbom/scope/scope_test.go new file mode 100644 index 000000000..e663c08af --- /dev/null +++ b/imgbom/scope/scope_test.go @@ -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) + } + + }) + } +} diff --git a/imgbom/scope/test-fixtures/path-detected/.vimrc b/imgbom/scope/test-fixtures/path-detected/.vimrc new file mode 100644 index 000000000..93b07e21b --- /dev/null +++ b/imgbom/scope/test-fixtures/path-detected/.vimrc @@ -0,0 +1 @@ +" A .vimrc file diff --git a/imgbom/scope/test-fixtures/path-detected/empty b/imgbom/scope/test-fixtures/path-detected/empty new file mode 100644 index 000000000..e69de29bb diff --git a/integration/dir_presenters_test.go b/integration/dir_presenters_test.go new file mode 100644 index 000000000..b9233c8cd --- /dev/null +++ b/integration/dir_presenters_test.go @@ -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)) + } + +} diff --git a/integration/fixture_image_language_pkgs_test.go b/integration/fixture_image_language_pkgs_test.go index ed04e25c0..8d18e143b 100644 --- a/integration/fixture_image_language_pkgs_test.go +++ b/integration/fixture_image_language_pkgs_test.go @@ -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) } diff --git a/integration/test-fixtures/snapshot/TestDirJsonPresenter.golden b/integration/test-fixtures/snapshot/TestDirJsonPresenter.golden new file mode 100644 index 000000000..9406dbebc --- /dev/null +++ b/integration/test-fixtures/snapshot/TestDirJsonPresenter.golden @@ -0,0 +1 @@ +{"artifacts":[{"name":"actionmailer","version":"4.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"actionpack","version":"4.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"actionview","version":"4.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"activemodel","version":"4.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"activerecord","version":"4.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"activesupport","version":"4.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"arel","version":"5.0.1.20140414130214","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"bootstrap-sass","version":"3.1.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"builder","version":"3.2.2","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"coffee-rails","version":"4.0.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"coffee-script","version":"2.2.0","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"coffee-script-source","version":"1.7.0","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"erubis","version":"2.7.0","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"execjs","version":"2.0.2","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"hike","version":"1.2.3","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"i18n","version":"0.6.9","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"jbuilder","version":"2.0.7","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"jquery-rails","version":"3.1.0","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"json","version":"1.8.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"kgio","version":"2.9.2","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"libv8","version":"3.16.14.3","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"mail","version":"2.5.4","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"mime-types","version":"1.25.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"minitest","version":"5.3.4","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"multi_json","version":"1.10.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"mysql2","version":"0.3.16","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"polyglot","version":"0.3.4","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"rack","version":"1.5.2","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"rack-test","version":"0.6.2","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"rails","version":"4.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"railties","version":"4.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"raindrops","version":"0.13.0","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"rake","version":"10.3.2","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"rdoc","version":"4.1.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"ref","version":"1.0.5","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"sass","version":"3.2.19","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"sass-rails","version":"4.0.3","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"sdoc","version":"0.4.0","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"spring","version":"1.1.3","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"sprockets","version":"2.11.0","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"sprockets-rails","version":"2.1.3","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"sqlite3","version":"1.3.9","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"therubyracer","version":"0.12.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"thor","version":"0.19.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"thread_safe","version":"0.3.3","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"tilt","version":"1.4.1","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"treetop","version":"1.4.15","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"turbolinks","version":"2.2.2","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"tzinfo","version":"1.2.0","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"uglifier","version":"2.5.0","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null},{"name":"unicorn","version":"4.8.3","type":"bundle","cataloger":"","sources":[{"foundBy":"bundler-cataloger","effects":[]}],"metadata":null}],"Source":"test-fixtures"} \ No newline at end of file diff --git a/integration/test-fixtures/snapshot/TestDirTextPresenter.golden b/integration/test-fixtures/snapshot/TestDirTextPresenter.golden new file mode 100644 index 000000000..de5364a32 --- /dev/null +++ b/integration/test-fixtures/snapshot/TestDirTextPresenter.golden @@ -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 +