Skip to content
Merged
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
33 changes: 31 additions & 2 deletions cmd/sbom/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -52,6 +54,15 @@ func run(args []string, getenv func(string) string, stdout, stderr io.Writer) in
only := fs.String("only", "", "the packages shipped, comma-separated (apk)")
pins := fs.String("versions", versions.File, "the versions.yaml the pins are in (pins)")
subjectFile := fs.String("subject", "", "the file the merged BOM describes, by its hash (merge)")
patches := map[string]string{}
fs.Func("patches", "<name>=<dir>: the entry was built with dir's *.patch applied, in name order (pins)", func(v string) error {
n, dir, ok := strings.Cut(v, "=")
if !ok || n == "" || dir == "" {
return fmt.Errorf("%q is not <name>=<dir>", v)
}
patches[n] = dir
return nil
})
if err := fs.Parse(args); err != nil {
return 2
}
Expand All @@ -72,7 +83,7 @@ func run(args []string, getenv func(string) string, stdout, stderr io.Writer) in
say(stderr, "sbom: no kind", kind)
return 2
}
b, err := build(request{subject: subject, epoch: epoch, files: fs.Args(), distro: *distro, only: *only, pins: *pins, using: *subjectFile})
b, err := build(request{subject: subject, epoch: epoch, files: fs.Args(), distro: *distro, only: *only, pins: *pins, using: *subjectFile, patches: patches})
if err != nil {
say(stderr, err)
return 2
Expand All @@ -90,6 +101,7 @@ type request struct {
epoch time.Time
files []string
distro, only, pins, using string
patches map[string]string
}

// one is the request's one file, read.
Expand Down Expand Up @@ -164,7 +176,24 @@ var kinds = map[string]func(r request) (*cdx.BOM, error){
return nil, err
}
cs, err := sbom.Pins(v, r.files...)
return r.of(cs, nil, err)
if err != nil {
return nil, err
}
for i, c := range cs {
dir, ok := r.patches[c.Name]
if !ok {
continue
}
found, err := filepath.Glob(filepath.Join(dir, "*.patch"))
if err != nil || len(found) == 0 {
return nil, fmt.Errorf("sbom: %s has no *.patch in %s", c.Name, dir)
}
slices.Sort(found)
if cs[i], err = sbom.Patched(c, found...); err != nil {
return nil, err
}
}
return r.of(cs, nil, nil)
},
"file": func(r request) (*cdx.BOM, error) {
if len(r.files) != 1 {
Expand Down
32 changes: 26 additions & 6 deletions cmd/sbom/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ func TestEachKindWritesACheckedBOM(t *testing.T) {
pnpm := write("pnpm-lock.yaml", "importers:\n .:\n dependencies:\n react:\n version: 19.2.0\npackages:\n react@19.2.0:\n resolution: {integrity: sha512-AAAA}\nsnapshots:\n react@19.2.0: {}\n")
pins := write("versions.yaml", "- name: snapshot\n kind: date\n version: 20261001T000000Z\n track: today\n")
blob := write("bios.bin", "seabios")
patchDir := filepath.Join(dir, "patches")
require.NoError(t, os.MkdirAll(patchDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(patchDir, "b.patch"), []byte("--- b\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(patchDir, "a.patch"), []byte("--- a\n"), 0o600))
for name, tc := range map[string]struct {
args []string
status int
Expand All @@ -39,6 +43,11 @@ func TestEachKindWritesACheckedBOM(t *testing.T) {
"apk": {args: []string{"apk", "-distro", "alpine", "-only", "musl", db}},
"pnpm": {args: []string{"pnpm", pnpm}},
"pins": {args: []string{"pins", "-versions", pins, "snapshot"}},
"pins, patched": {args: []string{"pins", "-versions", pins, "-patches", "snapshot=" + patchDir, "snapshot"}},
"patches of no dir": {args: []string{"pins", "-versions", pins, "-patches", "snapshot=" + filepath.Join(dir, "none"), "snapshot"}, status: 2},
"patches not n=dir": {args: []string{"pins", "-versions", pins, "-patches", "snapshot", "snapshot"}, status: 2},
"patches of no name": {args: []string{"pins", "-versions", pins, "-patches", "=" + patchDir, "snapshot"}, status: 2},
"patches in no dir": {args: []string{"pins", "-versions", pins, "-patches", "snapshot=", "snapshot"}, status: 2},
"file": {args: []string{"file", blob}},
"dpkg with no distro": {args: []string{"dpkg", pkgs}, status: 2},
"apk with no only": {args: []string{"apk", "-distro", "alpine", db}, status: 2},
Expand Down Expand Up @@ -70,6 +79,14 @@ func TestEachKindWritesACheckedBOM(t *testing.T) {
require.NoError(t, err)
require.NoError(t, sbom.Check(b))
assert.Equal(t, "spin", b.Metadata.Component.Name)
if name == "pins, patched" {
// The directory's patches, in name order, as the build applies them.
var urls []string
for _, p := range *(*b.Components)[0].Pedigree.Patches {
urls = append(urls, p.Diff.URL)
}
assert.Equal(t, []string{"a.patch", "b.patch"}, urls)
}
assert.Equal(t, 0, run([]string{"check", out}, nil, &stdout, &stderr))
})
}
Expand All @@ -82,20 +99,23 @@ func TestARunMissingWhatItNeedsWritesNothing(t *testing.T) {
blob := filepath.Join(dir, "f")
require.NoError(t, os.WriteFile(blob, []byte("x"), 0o600))
out := filepath.Join(dir, "x.cdx.json")
const required = "-name, -version and -out are required"
for name, tc := range map[string]struct {
args []string
epoch string
says string
}{
"nothing": {},
"no name": {args: []string{"file", "-version", "v1", "-out", out, blob}},
"no version": {args: []string{"file", "-name", "f", "-out", out, blob}},
"no out": {args: []string{"file", "-name", "f", "-version", "v1", blob}},
"a bad epoch": {args: []string{"file", "-name", "f", "-version", "v1", "-out", out, blob}, epoch: "soon"},
"an out not there": {args: []string{"file", "-name", "f", "-version", "v1", "-out", filepath.Join(dir, "no", "x.json"), blob}},
"nothing": {says: "say go"},
"no name": {args: []string{"file", "-version", "v1", "-out", out, blob}, says: required},
"no version": {args: []string{"file", "-name", "f", "-out", out, blob}, says: required},
"no out": {args: []string{"file", "-name", "f", "-version", "v1", blob}, says: required},
"a bad epoch": {args: []string{"file", "-name", "f", "-version", "v1", "-out", out, blob}, epoch: "soon", says: "SOURCE_DATE_EPOCH"},
"an out not there": {args: []string{"file", "-name", "f", "-version", "v1", "-out", filepath.Join(dir, "no", "x.json"), blob}, says: "no such file"},
} {
t.Run(name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
assert.Equal(t, 2, run(tc.args, func(string) string { return tc.epoch }, &stdout, &stderr))
assert.Contains(t, stderr.String(), tc.says)
_, err := os.Stat(out)
assert.True(t, os.IsNotExist(err))
})
Expand Down
29 changes: 17 additions & 12 deletions cmd/versions/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Usage:
versions env <name>... the same as KEY=value lines, for a shell to read
versions version <name> one entry's version
versions ref <name> an image's reference, by its digest
versions url <name> a download's URL, at its version
versions check what is behind its upstream; exits 1 when anything is
versions bump <name> [version] pin an entry at a version, the newest by default

Expand Down Expand Up @@ -92,8 +93,8 @@ func run(ctx context.Context, argv []string) error {
}
fmt.Println(strings.Join(pins, sep))
return nil
case "version", "ref":
return one(v, cmd, names)
case "version", "ref", "url":
return one(os.Stdout, v, cmd, names)
case "check":
if len(names) != 0 {
return errors.New("check: takes no names; it checks every entry")
Expand All @@ -107,24 +108,28 @@ func run(ctx context.Context, argv []string) error {
}
}

// one says one entry's version, or an image's reference.
func one(v *versions.Versions, cmd string, names []string) error {
// one says one entry's version, an image's reference, or a download's URL.
func one(w io.Writer, v *versions.Versions, cmd string, names []string) error {
if len(names) != 1 {
return fmt.Errorf("%s: name one entry", cmd)
}
e, err := v.Get(names[0])
if err != nil {
return err
}
if cmd == "version" {
fmt.Println(e.Version)
return nil
}
if e.Kind != versions.Image {
return fmt.Errorf("%s is a %s, not an image", e.Name, e.Kind)
var out string
switch {
case cmd == "version":
out = e.Version
case cmd == "url" && e.Kind == versions.Download:
out = e.URL()
case cmd == "ref" && e.Kind == versions.Image:
out = e.Ref()
default:
return fmt.Errorf("%s: %s is a %s", cmd, e.Name, e.Kind)
}
fmt.Println(e.Ref())
return nil
_, err = fmt.Fprintln(w, out)
return err
}

// bump pins an entry at a version, the newest by default, and writes the file.
Expand Down
118 changes: 118 additions & 0 deletions cmd/versions/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package main

import (
"bytes"
"io"
"os"
"path/filepath"
"testing"

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

"github.com/spin-stack/go-tools/versions"
)

// stdout is what f printed to os.Stdout.
func stdout(t *testing.T, f func()) string {
t.Helper()
r, w, err := os.Pipe()
require.NoError(t, err)
was := os.Stdout
os.Stdout = w
f()
os.Stdout = was
require.NoError(t, w.Close())
out, err := io.ReadAll(r)
require.NoError(t, err)
return string(out)
}

// Each command answers from the file it is given, and refuses what it is not asked as it reads:
// no command, an unknown one, a flag it has not, a file not there, a name it needs and lacks.
// check and bump reach upstreams, so only what they refuse before that is asked here.
func TestTheCommandsAnswerFromTheFile(t *testing.T) {
file := filepath.Join(t.TempDir(), "versions.yaml")
require.NoError(t, os.WriteFile(file, []byte(`
- name: kernel
kind: download
source: https://cdn.kernel.org/pub/linux/kernel/v{major}.x/linux-{version}.tar.xz
version: 7.2.8
pin: 1111111111111111111111111111111111111111111111111111111111111111
track: kernel-stable
`), 0o600))
for name, tc := range map[string]struct {
argv []string
out string
fails bool
}{
"args": {argv: []string{"args", "-file", file, "kernel"}, out: "--build-arg KERNEL_SHA256=1111111111111111111111111111111111111111111111111111111111111111 --build-arg KERNEL_VERSION=7.2.8\n"},
"env": {argv: []string{"env", "-file", file, "kernel"}, out: "KERNEL_SHA256=1111111111111111111111111111111111111111111111111111111111111111\nKERNEL_VERSION=7.2.8\n"},
"version": {argv: []string{"version", "-file", file, "kernel"}, out: "7.2.8\n"},
"url": {argv: []string{"url", "-file", file, "kernel"}, out: "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.2.8.tar.xz\n"},
"help": {argv: []string{"help"}, out: usage},
"-h": {argv: []string{"-h"}, out: usage},
"--help": {argv: []string{"--help"}, out: usage},
"nothing": {fails: true},
"an unknown one": {argv: []string{"frob", "-file", file}, fails: true},
"an unknown flag": {argv: []string{"version", "-nope"}, fails: true},
"help as a flag": {argv: []string{"version", "-h"}, out: usage, fails: true},
"no file": {argv: []string{"version", "-file", filepath.Join(t.TempDir(), "none"), "kernel"}, fails: true},
"args of nothing": {argv: []string{"args", "-file", file}, fails: true},
"args of none": {argv: []string{"args", "-file", file, "qemu"}, fails: true},
"check of names": {argv: []string{"check", "-file", file, "kernel"}, fails: true},
"bump of nothing": {argv: []string{"bump", "-file", file}, fails: true},
"bump of three": {argv: []string{"bump", "-file", file, "a", "b", "c"}, fails: true},
"bump of none": {argv: []string{"bump", "-file", file, "qemu"}, fails: true},
} {
t.Run(name, func(t *testing.T) {
var err error
out := stdout(t, func() { err = run(t.Context(), tc.argv) })
assert.Equal(t, tc.fails, err != nil, "err = %v", err)
assert.Equal(t, tc.out, out)
})
}
}

// One entry says its version, an image its reference and a download its URL; asked what its kind
// has not, or of no entry or two, it says nothing.
func TestOneEntrySaysWhatItsKindHas(t *testing.T) {
v, err := versions.Parse([]byte(`
- name: kernel
kind: download
source: https://git.kernel.org/torvalds/t/linux-{version}.tar.gz
version: 7.3-rc5
pin: 1111111111111111111111111111111111111111111111111111111111111111
track: kernel-stable
- name: alpine
kind: image
source: alpine
version: "3.24"
pin: sha256:2222222222222222222222222222222222222222222222222222222222222222
track: digest
`))
require.NoError(t, err)
for name, tc := range map[string]struct {
cmd, entry, out string
}{
"a version": {cmd: "version", entry: "kernel", out: "7.3-rc5\n"},
"a download's URL": {cmd: "url", entry: "kernel", out: "https://git.kernel.org/torvalds/t/linux-7.3-rc5.tar.gz\n"},
"an image's ref": {cmd: "ref", entry: "alpine", out: "alpine:3.24@sha256:2222222222222222222222222222222222222222222222222222222222222222\n"},
"an image's URL": {cmd: "url", entry: "alpine"},
"a download's ref": {cmd: "ref", entry: "kernel"},
"no such entry": {cmd: "version", entry: "qemu"},
} {
t.Run(name, func(t *testing.T) {
var w bytes.Buffer
err := one(&w, v, tc.cmd, []string{tc.entry})
if tc.out == "" {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.out, w.String())
})
}
require.Error(t, one(&bytes.Buffer{}, v, "version", nil))
require.Error(t, one(&bytes.Buffer{}, v, "version", []string{"kernel", "alpine"}))
}
20 changes: 20 additions & 0 deletions sbom/builders.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,26 @@ func purlName(name string) string {
return name
}

// Patched is c built with the patches at paths applied to its source, in that order: each, its
// text and its name, in c's pedigree, so what was compiled is the upstream and these changes to
// it, and a scanner can tell a patched component from the upstream one.
func Patched(c cdx.Component, paths ...string) (cdx.Component, error) {
if len(paths) == 0 {
return c, nil
}
var patches []cdx.Patch
for _, p := range paths {
raw, err := os.ReadFile(p) //nolint:gosec // a patch the build applied
if err != nil {
return cdx.Component{}, fmt.Errorf("sbom: %w", err)
}
patches = append(patches, cdx.Patch{Type: cdx.PatchTypeUnofficial,
Diff: &cdx.Diff{URL: filepath.Base(p), Text: &cdx.AttachedText{ContentType: "text/x-diff", Content: string(raw)}}})
}
c.Pedigree = &cdx.Pedigree{Patches: &patches}
return c, nil
}

// Pins are the versions.yaml entries named, each as what it is: a download by its URL and
// SHA-256, a repository by its commit, an image by its digest, a module by its version.
func Pins(v *versions.Versions, names ...string) ([]cdx.Component, error) {
Expand Down
22 changes: 22 additions & 0 deletions sbom/sbom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,28 @@ func TestAGoBinaryIsItsBuildInfo(t *testing.T) {
require.Error(t, err)
}

// A patched component carries each patch, its name and text, in its pedigree, in the order given;
// with none it is as it was, and a patch that is not there is an error.
func TestAPatchedComponentCarriesItsPatches(t *testing.T) {
dir := t.TempDir()
a, b := filepath.Join(dir, "0001-a.patch"), filepath.Join(dir, "0002-b.patch")
require.NoError(t, os.WriteFile(a, []byte("--- a\n+++ a\n"), 0o600))
require.NoError(t, os.WriteFile(b, []byte("--- b\n"), 0o600))
qemu := component("qemu", "11.1.2", "pkg:generic/qemu@11.1.2")
got, err := sbom.Patched(qemu, a, b)
require.NoError(t, err)
patches := *got.Pedigree.Patches
require.Len(t, patches, 2)
assert.Equal(t, "0001-a.patch", patches[0].Diff.URL)
assert.Equal(t, "--- a\n+++ a\n", patches[0].Diff.Text.Content)
assert.Equal(t, cdx.PatchTypeUnofficial, patches[1].Type)
same, err := sbom.Patched(qemu)
require.NoError(t, err)
assert.Nil(t, same.Pedigree)
_, err = sbom.Patched(qemu, filepath.Join(dir, "none.patch"))
require.Error(t, err)
}

// A file is a component by its SHA-256.
func TestAFileIsItsHash(t *testing.T) {
path := filepath.Join(t.TempDir(), "bios.bin")
Expand Down