Skip to content

Commit

Permalink
Add native support for php/composerlock extraction.
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 676800059
  • Loading branch information
erikvarga authored and copybara-github committed Sep 20, 2024
1 parent 85171e0 commit 0269a5b
Show file tree
Hide file tree
Showing 10 changed files with 658 additions and 2 deletions.
2 changes: 1 addition & 1 deletion docs/supported_inventory_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ SCALIBR supports extracting software package information from a variety of OS an
* Installed NPM packages (package.json)
* Lockfiles: package-lock.json, yarn.lock (OSV), pnpm-lock.yaml
* PHP:
* Composer (OSV)
* Composer
* Python
* Installed PyPI packages (global and venv)
* Lockfiles: requirements.txt, poetry (OSV), Pipfile.lock (OSV)
Expand Down
134 changes: 134 additions & 0 deletions extractor/filesystem/language/php/composerlock/extractor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package composerlock extracts composer.lock files.
package composerlock

import (
"context"
"encoding/json"
"fmt"
"io/fs"
"path/filepath"

"github.com/google/osv-scalibr/extractor"
"github.com/google/osv-scalibr/extractor/filesystem"
"github.com/google/osv-scalibr/extractor/filesystem/osv"
"github.com/google/osv-scalibr/plugin"
"github.com/google/osv-scalibr/purl"
)

type composerPackage struct {
Name string `json:"name"`
Version string `json:"version"`
Dist struct {
Reference string `json:"reference"`
} `json:"dist"`
}

type composerLock struct {
Packages []composerPackage `json:"packages"`
PackagesDev []composerPackage `json:"packages-dev"`
}

// ComposerEcosystem is the OSV ecosystem for packages described in composer.lock files.
const ComposerEcosystem string = "Packagist"

// Extractor extracts composer.lock files.
type Extractor struct{}

// Name of the extractor.
func (e Extractor) Name() string { return "php/composerlock" }

// Version of the extractor.
func (e Extractor) Version() int { return 0 }

// Requirements of the extractor.
func (e Extractor) Requirements() *plugin.Capabilities {
return &plugin.Capabilities{}
}

// FileRequired returns true if the specified file matches composer.lock files.
func (e Extractor) FileRequired(path string, fileInfo fs.FileInfo) bool {
return filepath.Base(path) == "composer.lock"
}

// Extract extracts packages from a composer.lock file passed through the scan input.
func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) ([]*extractor.Inventory, error) {
var parsedLockfile *composerLock

err := json.NewDecoder(input.Reader).Decode(&parsedLockfile)

if err != nil {
return []*extractor.Inventory{}, fmt.Errorf("could not extract from %s: %w", input.Path, err)
}

packages := make(
[]*extractor.Inventory,
0,
// len cannot return negative numbers, but the types can't reflect that
uint64(len(parsedLockfile.Packages))+uint64(len(parsedLockfile.PackagesDev)),
)

for _, composerPackage := range parsedLockfile.Packages {
packages = append(packages, &extractor.Inventory{
Name: composerPackage.Name,
Version: composerPackage.Version,
Locations: []string{input.Path},
SourceCode: &extractor.SourceCodeIdentifier{
Commit: composerPackage.Dist.Reference,
},
Metadata: osv.DepGroupMetadata{
DepGroupVals: []string{},
},
})
}

for _, composerPackage := range parsedLockfile.PackagesDev {
packages = append(packages, &extractor.Inventory{
Name: composerPackage.Name,
Version: composerPackage.Version,
Locations: []string{input.Path},
SourceCode: &extractor.SourceCodeIdentifier{
Commit: composerPackage.Dist.Reference,
},
Metadata: osv.DepGroupMetadata{
DepGroupVals: []string{"dev"},
},
})
}

return packages, nil
}

// ToPURL converts an inventory created by this extractor into a PURL.
func (e Extractor) ToPURL(i *extractor.Inventory) (*purl.PackageURL, error) {
return &purl.PackageURL{
Type: purl.TypeComposer,
Name: i.Name,
Version: i.Version,
}, nil
}

// ToCPEs is not applicable as this extractor does not infer CPEs from the Inventory.
func (e Extractor) ToCPEs(i *extractor.Inventory) ([]string, error) {
return []string{}, nil
}

// Ecosystem returns the OSV Ecosystem of the software extracted by this extractor.
func (e Extractor) Ecosystem(i *extractor.Inventory) (string, error) {
return ComposerEcosystem, nil
}

var _ filesystem.Extractor = Extractor{}
221 changes: 221 additions & 0 deletions extractor/filesystem/language/php/composerlock/extractor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package composerlock_test

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/osv-scalibr/extractor"
"github.com/google/osv-scalibr/extractor/filesystem/language/php/composerlock"
"github.com/google/osv-scalibr/extractor/filesystem/osv"
"github.com/google/osv-scalibr/testing/extracttest"
)

func TestExtractor_FileRequired(t *testing.T) {
t.Parallel()

tests := []struct {
name string
inputPath string
want bool
}{
{
name: "empty name",
inputPath: "",
want: false,
},
{
name: "composer.lock from root",
inputPath: "composer.lock",
want: true,
},
{
name: "composer.lock from subpath",
inputPath: "path/to/my/composer.lock",
want: true,
},
{
name: "composer.lock as a dir",
inputPath: "path/to/my/composer.lock/file",
want: false,
},
{
name: "composer.lock with additional extension",
inputPath: "path/to/my/composer.lock.file",
want: false,
},
{
name: "composer.lock as substring",
inputPath: "path.to.my.composer.lock",
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := composerlock.Extractor{}
got := e.FileRequired(tt.inputPath, nil)
if got != tt.want {
t.Errorf("FileRequired(%s, FileInfo) got = %v, want %v", tt.inputPath, got, tt.want)
}
})
}
}

func TestExtractor_Extract(t *testing.T) {
t.Parallel()

tests := []extracttest.TestTableEntry{
{
Name: "invalid json",
InputConfig: extracttest.ScanInputMockConfig{
Path: "testdata/not-json.txt",
},
WantInventory: []*extractor.Inventory{},
WantErr: extracttest.ContainsErrStr{Str: "could not extract from"},
},
{
Name: "no packages",
InputConfig: extracttest.ScanInputMockConfig{
Path: "testdata/empty.json",
},
WantInventory: []*extractor.Inventory{},
},
{
Name: "one package",
InputConfig: extracttest.ScanInputMockConfig{
Path: "testdata/one-package.json",
},
WantInventory: []*extractor.Inventory{
{
Name: "sentry/sdk",
Version: "2.0.4",
Locations: []string{"testdata/one-package.json"},
SourceCode: &extractor.SourceCodeIdentifier{
Commit: "4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
},
Metadata: osv.DepGroupMetadata{
DepGroupVals: []string{},
},
},
},
},
{
Name: "one package dev",
InputConfig: extracttest.ScanInputMockConfig{
Path: "testdata/one-package-dev.json",
},
WantInventory: []*extractor.Inventory{
{
Name: "sentry/sdk",
Version: "2.0.4",
Locations: []string{"testdata/one-package-dev.json"},
SourceCode: &extractor.SourceCodeIdentifier{
Commit: "4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
},
Metadata: osv.DepGroupMetadata{
DepGroupVals: []string{"dev"},
},
},
},
},
{
Name: "two packages",
InputConfig: extracttest.ScanInputMockConfig{
Path: "testdata/two-packages.json",
},
WantInventory: []*extractor.Inventory{
{
Name: "sentry/sdk",
Version: "2.0.4",
Locations: []string{"testdata/two-packages.json"},
SourceCode: &extractor.SourceCodeIdentifier{
Commit: "4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
},
Metadata: osv.DepGroupMetadata{
DepGroupVals: []string{},
},
},
{
Name: "theseer/tokenizer",
Version: "1.1.3",
Locations: []string{"testdata/two-packages.json"},
SourceCode: &extractor.SourceCodeIdentifier{
Commit: "11336f6f84e16a720dae9d8e6ed5019efa85a0f9",
},
Metadata: osv.DepGroupMetadata{
DepGroupVals: []string{"dev"},
},
},
},
},
{
Name: "two packages alt",
InputConfig: extracttest.ScanInputMockConfig{
Path: "testdata/two-packages-alt.json",
},
WantInventory: []*extractor.Inventory{
{
Name: "sentry/sdk",
Version: "2.0.4",
Locations: []string{"testdata/two-packages-alt.json"},
SourceCode: &extractor.SourceCodeIdentifier{
Commit: "4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
},
Metadata: osv.DepGroupMetadata{
DepGroupVals: []string{},
},
},
{
Name: "theseer/tokenizer",
Version: "1.1.3",
Locations: []string{"testdata/two-packages-alt.json"},
SourceCode: &extractor.SourceCodeIdentifier{
Commit: "11336f6f84e16a720dae9d8e6ed5019efa85a0f9",
},
Metadata: osv.DepGroupMetadata{
DepGroupVals: []string{},
},
},
},
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
extr := composerlock.Extractor{}

scanInput := extracttest.GenerateScanInputMock(t, tt.InputConfig)
defer extracttest.CloseTestScanInput(t, scanInput)

got, err := extr.Extract(context.Background(), &scanInput)

if diff := cmp.Diff(tt.WantErr, err, cmpopts.EquateErrors()); diff != "" {
t.Errorf("%s.Extract(%q) error diff (-want +got):\n%s", extr.Name(), tt.InputConfig.Path, diff)
return
}

if diff := cmp.Diff(tt.WantInventory, got, cmpopts.SortSlices(extracttest.InventoryCmpLess)); diff != "" {
t.Errorf("%s.Extract(%q) diff (-want +got):\n%s", extr.Name(), tt.InputConfig.Path, diff)
}
})
}
}
19 changes: 19 additions & 0 deletions extractor/filesystem/language/php/composerlock/testdata/empty.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"content-hash": "439b16dd5df2e0730bd1cc4352654d09",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": [],
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^7.1.3"
},
"platform-dev": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
this is not json!
Loading

0 comments on commit 0269a5b

Please sign in to comment.