Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions syft/format/common/cyclonedxhelpers/to_format_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cpe"
"github.com/anchore/syft/syft/file"
formatinternal "github.com/anchore/syft/syft/format/internal"
"github.com/anchore/syft/syft/format/internal/cyclonedxutil/helpers"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
Expand Down Expand Up @@ -55,7 +56,6 @@ func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM {
artifacts := s.Artifacts

for _, coordinate := range coordinates {
var metadata *file.Metadata
// File Info
fileMetadata, exists := artifacts.FileMetadata[coordinate]
// no file metadata then don't include in SBOM
Expand All @@ -70,7 +70,6 @@ func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM {
// skip dir, symlinks and sockets for the final bom
continue
}
metadata = &fileMetadata

// Digests
var digests []file.Digest
Expand All @@ -79,10 +78,11 @@ func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM {
}

cdxHashes := digestsToHashes(digests)
relativePath := fileRelativePath(s.Source, coordinate.RealPath)
components = append(components, cyclonedx.Component{
BOMRef: string(coordinate.ID()),
Type: cyclonedx.ComponentTypeFile,
Name: metadata.Path,
Name: relativePath,
Hashes: &cdxHashes,
})
}
Expand Down Expand Up @@ -127,6 +127,18 @@ func digestsToHashes(digests []file.Digest) []cyclonedx.Hash {
return hashes
}

// fileRelativePath returns the path for a file component in the BOM.
// When the source is a directory scan with a --base-path set, paths are made
// relative to that base (supporting ".." for symlinks that escape the base).
// For all other source types the absolute path is stripped to a simple relative
// path via ConvertAbsoluteToRelative.
func fileRelativePath(src source.Description, realPath string) string {
if m, ok := src.Metadata.(source.DirectoryMetadata); ok && m.Base != "" {
return formatinternal.Rel(m.Base, realPath)
}
return formatinternal.ConvertAbsoluteToRelative(realPath)
}

func toOSComponent(distro *linux.Release) []cyclonedx.Component {
if distro == nil {
return []cyclonedx.Component{}
Expand Down
59 changes: 55 additions & 4 deletions syft/format/common/cyclonedxhelpers/to_format_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func Test_FileComponents(t *testing.T) {
},
{
BOMRef: "3f31cb2d98be6c1e",
Name: "/test",
Name: "test",
Type: cyclonedx.ComponentTypeFile,
Hashes: &[]cyclonedx.Hash{
{Algorithm: "SHA-256", Value: "xyz12345"},
Expand Down Expand Up @@ -214,7 +214,7 @@ func Test_FileComponents(t *testing.T) {
want: []cyclonedx.Component{
{
BOMRef: "3f31cb2d98be6c1e",
Name: "/test",
Name: "test",
Type: cyclonedx.ComponentTypeFile,
Hashes: &[]cyclonedx.Hash{
{Algorithm: "SHA-256", Value: "xyz12345"},
Expand Down Expand Up @@ -246,7 +246,7 @@ func Test_FileComponents(t *testing.T) {
want: []cyclonedx.Component{
{
BOMRef: "3f31cb2d98be6c1e",
Name: "/test",
Name: "test",
Type: cyclonedx.ComponentTypeFile,
Hashes: &[]cyclonedx.Hash{
{Algorithm: "SHA-256", Value: "xyz678910"},
Expand Down Expand Up @@ -282,7 +282,7 @@ func Test_FileComponents(t *testing.T) {
want: []cyclonedx.Component{
{
BOMRef: "3f31cb2d98be6c1e",
Name: "/test",
Name: "test",
Type: cyclonedx.ComponentTypeFile,
Hashes: &[]cyclonedx.Hash{
{Algorithm: "SHA-256", Value: "xyz12345"},
Expand Down Expand Up @@ -317,6 +317,57 @@ func Test_FileComponents(t *testing.T) {
}
}

func TestToFormatModel_FileComponentName_BasePathParity(t *testing.T) {
tests := []struct {
name string
realPath string
metadataPath string
wantName string
}{
{
name: "base-relative path strips leading slash (SPDX parity)",
realPath: "/usr/bin/foo",
metadataPath: "/absolute/scanner/path/usr/bin/foo",
wantName: "usr/bin/foo",
},
{
name: "path without leading slash is preserved as-is",
realPath: "relative/path/bar",
metadataPath: "/absolute/scanner/path/relative/path/bar",
wantName: "relative/path/bar",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
coordinate := file.Coordinates{RealPath: tt.realPath}
s := sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(),
FileMetadata: map[file.Coordinates]file.Metadata{
coordinate: {Path: tt.metadataPath, Type: stfile.TypeRegular},
},
FileDigests: map[file.Coordinates][]file.Digest{
coordinate: {},
},
},
}
result := ToFormatModel(s)
require.NotNil(t, result.Components)
var fileComp *cyclonedx.Component
for i := range *result.Components {
c := (*result.Components)[i]
if c.Type == cyclonedx.ComponentTypeFile {
fileComp = &c
break
}
}
require.NotNil(t, fileComp, "expected a file component in CycloneDX output")
assert.Equal(t, tt.wantName, fileComp.Name)
})
}
}

func Test_toBomDescriptor(t *testing.T) {
type args struct {
name string
Expand Down
34 changes: 14 additions & 20 deletions syft/format/common/spdxhelpers/to_format_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
"github.com/anchore/syft/internal/spdxlicense"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
formatInternal "github.com/anchore/syft/syft/format/internal"
formatinternal "github.com/anchore/syft/syft/format/internal"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we avoid these import renames?

"github.com/anchore/syft/syft/format/internal/spdxutil/helpers"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
Expand Down Expand Up @@ -680,7 +680,7 @@ func lookupRelationship(ty artifact.RelationshipType) (bool, helpers.Relationshi
func toFiles(s sbom.SBOM) (results []*spdx.File) {
artifacts := s.Artifacts

_, coordinateSorter := formatInternal.GetLocationSorters(s)
_, coordinateSorter := formatinternal.GetLocationSorters(s)

coordinates := s.AllCoordinates()
slices.SortFunc(coordinates, coordinateSorter)
Expand Down Expand Up @@ -712,11 +712,7 @@ func toFiles(s sbom.SBOM) (results []*spdx.File) {
comment = fmt.Sprintf("layerID: %s", c.FileSystemID)
}

relativePath, err := convertAbsoluteToRelative(c.RealPath)
if err != nil {
log.Debugf("unable to convert relative path '%s' to absolute path: %s", c.RealPath, err)
relativePath = c.RealPath
}
relativePath := spdxFileRelativePath(s.Source, c.RealPath)

results = append(results, &spdx.File{
FileSPDXIdentifier: toSPDXID(c),
Expand Down Expand Up @@ -870,21 +866,19 @@ func trimPatchVersion(semver string) string {

// spdx requires that the file name field is a relative filename
// with the root of the package archive or directory
func convertAbsoluteToRelative(absPath string) (string, error) {
// Ensure the absolute path is absolute (although it should already be)
if !path.IsAbs(absPath) {
// already relative
log.Debugf("%s is already relative", absPath)
return absPath, nil
}
func convertAbsoluteToRelative(absPath string) string {
return formatinternal.ConvertAbsoluteToRelative(absPath)
}

// we use "/" here given that we're converting absolute paths from root to relative
relPath, found := strings.CutPrefix(absPath, "/")
if !found {
return "", fmt.Errorf("error calculating relative path: %s", absPath)
// spdxFileRelativePath returns the file name to embed in the SPDX document.
// When the source is a directory scan with a --base-path set, paths are made
// relative to that base (supporting ".." for symlinks that escape the base).
// For all other source types the absolute leading "/" is simply stripped.
func spdxFileRelativePath(src source.Description, realPath string) string {
if m, ok := src.Metadata.(source.DirectoryMetadata); ok && m.Base != "" {
return formatinternal.Rel(m.Base, realPath)
}

return relPath, nil
return convertAbsoluteToRelative(realPath)
}

func convertOtherLicense(otherLicenses []spdx.OtherLicense) []*spdx.OtherLicense {
Expand Down
58 changes: 58 additions & 0 deletions syft/format/internal/relativepath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package internal

import (
"path"
"strings"
)

// Rel returns a forward-slash relative path from base to target, equivalent to
// filepath.Rel but for already-cleaned, forward-slash paths. Both base and target
// should be absolute-style (leading "/") paths. The result preserves ".." segments
// for targets that escape the base, so symlinks whose real path lives outside the
// scanned base directory are represented correctly (e.g. "../foo").
func splitPath(p string) []string {
parts := strings.Split(strings.TrimPrefix(p, "/"), "/")
out := parts[:0]
for _, s := range parts {
if s != "" {
out = append(out, s)
}
}
return out
}

func Rel(base, target string) string {
if base == target {
return "."
}

baseSegs := splitPath(base)
targSegs := splitPath(target)

// Find the length of the common prefix.
n := 0
for n < len(baseSegs) && n < len(targSegs) && baseSegs[n] == targSegs[n] {
n++
}

// Build up-segments for any base segments beyond the common prefix.
up := make([]string, len(baseSegs)-n)
for i := range up {
up[i] = ".."
}

result := strings.Join(append(up, targSegs[n:]...), "/")
if result == "" {
return "."
}
return result
}

// ConvertAbsoluteToRelative strips the leading "/" from an absolute path.
// If the path is already relative it is returned unchanged.
func ConvertAbsoluteToRelative(absPath string) string {
if !path.IsAbs(absPath) {
return absPath
}
return strings.TrimPrefix(absPath, "/")
}
102 changes: 102 additions & 0 deletions syft/format/internal/relativepath_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package internal

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestConvertAbsoluteToRelative(t *testing.T) {
tests := []struct {
name string
absPath string
want string
}{
{
name: "absolute path",
absPath: "/usr/bin/foo",
want: "usr/bin/foo",
},
{
name: "relative path",
absPath: "relative/path/bar",
want: "relative/path/bar",
},
{
name: "root path",
absPath: "/",
want: "",
},
{
name: "dot relative path",
absPath: "./foo",
want: "./foo",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ConvertAbsoluteToRelative(tt.absPath)
assert.Equal(t, tt.want, got)
})
}
}

func TestRel(t *testing.T) {
tests := []struct {
name string
base string
target string
want string
}{
{
name: "escaping symlink: target is sibling of base parent",
base: "/root/subdir",
target: "/root/foo",
want: "../foo",
},
{
name: "target is child of base",
base: "/root/subdir",
target: "/root/subdir/foo",
want: "foo",
},
{
name: "base equals target",
base: "/root",
target: "/root",
want: ".",
},
{
name: "siblings under common parent",
base: "/root/a",
target: "/root/b",
want: "../b",
},
{
name: "target is immediate child of root",
base: "/",
target: "/foo",
want: "foo",
},
{
name: "deeper nesting",
base: "/a/b/c",
target: "/a/d/e",
want: "../../d/e",
},
{
name: "target equals base with trailing content",
base: "/a/b",
target: "/a/b/c/d",
want: "c/d",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Rel(tt.base, tt.target)
assert.Equal(t, tt.want, got)
})
}
}