-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add native support for php/composerlock extraction.
PiperOrigin-RevId: 676800059
- Loading branch information
1 parent
85171e0
commit 0269a5b
Showing
10 changed files
with
658 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
134 changes: 134 additions & 0 deletions
134
extractor/filesystem/language/php/composerlock/extractor.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
221
extractor/filesystem/language/php/composerlock/extractor_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
19
extractor/filesystem/language/php/composerlock/testdata/empty.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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": [] | ||
} |
1 change: 1 addition & 0 deletions
1
extractor/filesystem/language/php/composerlock/testdata/not-json.txt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
this is not json! |
Oops, something went wrong.