From 098abfa8939b4176273d431c1121878abc1c503a Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 13 Oct 2026 13:37:57 -0300 Subject: [PATCH] versions url, and sbom's patches as pedigree versions url says a download's URL at its version, for what writes it down - spin-machine's SOURCES named the kernel's at another host than the pin. sbom.Patched, and pins -patches name=dir, put each patch a build applied into its component's pedigree, its name and its text. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/sbom/main.go | 33 ++++++++++- cmd/sbom/main_test.go | 32 +++++++++-- cmd/versions/main.go | 29 ++++++---- cmd/versions/main_test.go | 118 ++++++++++++++++++++++++++++++++++++++ sbom/builders.go | 20 +++++++ sbom/sbom_test.go | 22 +++++++ 6 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 cmd/versions/main_test.go diff --git a/cmd/sbom/main.go b/cmd/sbom/main.go index e8586a5..e18221d 100644 --- a/cmd/sbom/main.go +++ b/cmd/sbom/main.go @@ -20,6 +20,8 @@ import ( "fmt" "io" "os" + "path/filepath" + "slices" "strings" "time" @@ -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", "=: 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 =", v) + } + patches[n] = dir + return nil + }) if err := fs.Parse(args); err != nil { return 2 } @@ -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 @@ -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. @@ -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 { diff --git a/cmd/sbom/main_test.go b/cmd/sbom/main_test.go index 69d088b..3c50f1a 100644 --- a/cmd/sbom/main_test.go +++ b/cmd/sbom/main_test.go @@ -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 @@ -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}, @@ -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)) }) } @@ -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)) }) diff --git a/cmd/versions/main.go b/cmd/versions/main.go index 63b9c30..a96c391 100644 --- a/cmd/versions/main.go +++ b/cmd/versions/main.go @@ -45,6 +45,7 @@ Usage: versions env ... the same as KEY=value lines, for a shell to read versions version one entry's version versions ref an image's reference, by its digest + versions url a download's URL, at its version versions check what is behind its upstream; exits 1 when anything is versions bump [version] pin an entry at a version, the newest by default @@ -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") @@ -107,8 +108,8 @@ 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) } @@ -116,15 +117,19 @@ func one(v *versions.Versions, cmd string, names []string) error { 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. diff --git a/cmd/versions/main_test.go b/cmd/versions/main_test.go new file mode 100644 index 0000000..2b45d6e --- /dev/null +++ b/cmd/versions/main_test.go @@ -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"})) +} diff --git a/sbom/builders.go b/sbom/builders.go index 77ea367..7d22ccb 100644 --- a/sbom/builders.go +++ b/sbom/builders.go @@ -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) { diff --git a/sbom/sbom_test.go b/sbom/sbom_test.go index 3084f18..01fdf90 100644 --- a/sbom/sbom_test.go +++ b/sbom/sbom_test.go @@ -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")