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
113 changes: 113 additions & 0 deletions cmd/bom/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Command bom renders the bill of materials for a binary this repository built,
// from the module table that binary carries.
//
// WHY THIS IS NOT A VERB OF THE RUNNER, and why it is a second command beside
// notices. lab reads a checkout. This reads a compiled artefact, which is not a
// checkout, and the release build is the only place it is ever run, so putting
// it inside lab would widen what an operator has to believe about a binary they
// downloaded in exchange for a verb that is useless outside a release. That is
// the judgement cmd/notices, cmd/contexts and cmd/pullrequest already made.
//
// It is separate from cmd/notices because the two produce different documents
// for different readers, which internal/bom argues at length, and because a
// single command writing two files would have to choose where each one lands.
// Where the files land is the release build's business.
//
// IT READS ONE PATH AND NOTHING ELSE. The binary to describe, given as the one
// argument. Unlike the notices it needs no module cache, because a bill of
// materials names components and reproduces no licence text, so nothing outside
// the binary is read at all. It asks no environment variable and runs no other
// program, so what it produced can be reproduced by hand from the path in the
// command line.
//
// IT OPENS NO CONNECTION. Everything it writes comes out of the binary it was
// given, so the document can be produced from an archive of a build with the
// network unplugged. That is the same claim the runner makes and it is made
// here for the same reason: a document that can only be produced online is a
// document that stops being producible.
package main

import (
"debug/buildinfo"
"fmt"
"io"
"os"

"github.com/Flowfin/lab/internal/bom"
"github.com/Flowfin/lab/internal/notices"
)

// The exit codes. Decision record 0011 is the contract, and this command
// returns the same three the runner does, for the same reasons, which is why
// they are written here with the record named rather than invented. The
// exit-code leg in internal/invariants reads every exit-code constant in the
// tree and refuses a code declared with two numbers, or a number declared under
// two codes, so keep the convention these three follow.
const (
// exitClean means the run completed and refused nothing. It does not mean
// the document is complete in the eyes of anybody consuming it, only that
// every module the binary carries reached it with a version, and that the
// build named a release of its own.
exitClean = 0

// exitRefused means the run completed and refused something. The document
// is still written, because a document that is incomplete by named
// entries is more useful than none, and the entries are named on standard
// error rather than inside it: a field this repository invented would be
// dropped in silence by anything reading the document as CycloneDX.
exitRefused = 1

// exitCannot means the command could not do its job: no argument, or a
// binary it cannot read a module table out of. It is deliberately not the
// code a refusal returns, because a gate that treats a broken invocation
// like a violation reports one as the other and nobody investigates
// either.
exitCannot = 2
)

const usage = "usage: bom <binary>"

func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}

// run is main with its edges passed in.
//
// The document goes to standard output rather than to a path this command
// chooses, which is the shape cmd/notices already has. Where the file lands is
// the release build's business, and a command that writes where it likes is one
// more thing to read before you can tell what a release contains.
func run(args []string, out, errOut io.Writer) int {
if len(args) != 1 {
fmt.Fprintf(errOut, "bom: %s\n", usage)
return exitCannot
}
binaryPath := args[0]

info, err := buildinfo.ReadFile(binaryPath)
if err != nil {
fmt.Fprintf(errOut, "bom: cannot read the module table of %s: %v\n", binaryPath, err)
return exitCannot
}

document := bom.Render(notices.BuildOf(info))
text, err := document.JSON()
if err != nil {
// Rendering is a pure function over values the toolchain wrote, so
// this is not a case anybody expects to meet. It is reported as the
// command being unable to do its job rather than as a refusal,
// because a document that was never written is not a document with a
// named gap in it.
fmt.Fprintf(errOut, "bom: %v\n", err)
return exitCannot
}
fmt.Fprint(out, text)

if len(document.Refusals) > 0 {
for _, refusal := range document.Refusals {
fmt.Fprintf(errOut, "bom: %s\n", refusal)
}
return exitRefused
}
return exitClean
}
192 changes: 192 additions & 0 deletions cmd/bom/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// What this command's own suite is for, and it is not a second copy of
// internal/bom's cases.
//
// The render is proved there, against module sets a case wrote out in full.
// What nothing there can prove is that the module table inside a real binary
// reaches that render at all: a build read wrongly, a field taken from the
// wrong place or a replacement flattened would leave every case green and
// produce a document describing nothing. So this builds a binary from this
// repository and reads that.
//
// WHERE THE COLLECTOR IS PROVED, because it is not proved here and a reader
// should not have to find that out by looking. The one place a dependency can
// be dropped between a binary and either document is notices.BuildOf, which is
// the single reader of the module table both commands call, and
// cmd/notices/tree_test.go builds a tree with a dependency in it and holds the
// document to naming it. That proof covers this command because it covers that
// function, and it stopped being two proofs when the second reader was removed.
package main

import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/Flowfin/lab/internal/bom"
)

// TestTheModuleTableOfARealBinaryReachesTheDocument builds the runner and
// describes it.
//
// THE MAIN MODULE IS WHAT IT ASSERTS ON, not the dependency list. This module
// has no third-party dependencies, so a list read out of the binary and a list
// that was never read look identical in that half. The main module path does
// not: it is in the binary and nowhere else this command looks, so a document
// naming it is a document that read the table. The revision is the second such
// field, and it comes from a build setting rather than from the module record,
// so the two together cover both halves of what BuildOf reads.
//
// IT DOES NOT REQUIRE ONE EXIT CODE. Whether this build carries a release
// version is a property of the checkout the suite is running in: a run at a tag
// carries one and a run on a branch carries a version derived from the commit.
// Both are legitimate here, so the test requires the code and the standard
// error to agree with each other rather than requiring a number the checkout
// decides.
func TestTheModuleTableOfARealBinaryReachesTheDocument(t *testing.T) {
binary := buildTheRunner(t)

var out, errOut bytes.Buffer
code := run([]string{binary}, &out, &errOut)

document := decode(t, out.Bytes())
if document.Metadata.Component.Name != "github.com/Flowfin/lab" {
t.Errorf("the document names %q as the module the binary was built from", document.Metadata.Component.Name)
}
if len(document.Components) != 0 {
t.Errorf("this module has no third-party dependency and the document lists %d component(s)", len(document.Components))
}
if !strings.Contains(out.String(), `"components": []`) {
t.Errorf("the empty component list is not written as an empty list, so a reader cannot tell it from a field nobody wrote:\n%s", out.String())
}

revision := ""
for _, property := range document.Metadata.Properties {
if property.Name == "lab:vcs.revision" {
revision = property.Value
}
}
if revision == "" {
t.Errorf("the document carries no revision property at all, so it says neither what the build was made at nor that it was made without one")
}

switch code {
case exitClean:
if errOut.Len() != 0 {
t.Errorf("returned %d and said %q, so a clean run wrote a complaint", exitClean, errOut.String())
}
case exitRefused:
// This module has no third-party dependency, so the only two things
// a build of it can be refused for are facts about the checkout the
// suite is running in: a working tree carrying changes, and a commit
// that is not a tag. Both are ordinary here and neither is a defect
// in the command, so what is required is that the run named one of
// them rather than returning a number with nothing behind it.
named := strings.Contains(errOut.String(), bom.MainComponentHasNoReleaseVersion) ||
strings.Contains(errOut.String(), bom.BuildIsFromAModifiedTree)
if !named {
t.Errorf("returned %d and named no property this build can be refused for: %q", exitRefused, errOut.String())
}
default:
t.Fatalf("returned %d, and stderr said %q", code, errOut.String())
}
t.Logf("described %s at version %q and revision %q, returning %d",
filepath.Base(binary), document.Metadata.Component.Version, revision, code)
}

// TestTheDocumentIsTheOnlyThingOnStandardOutput holds the command to writing a
// document a program can read.
//
// A refusal is written to standard error and never into the document, and this
// is where that is held rather than only stated. A run that mixed a complaint
// into the output would produce a file that is no longer JSON, and the reader
// that discovers it is somebody's scanner rather than this suite.
func TestTheDocumentIsTheOnlyThingOnStandardOutput(t *testing.T) {
binary := buildTheRunner(t)

var out, errOut bytes.Buffer
run([]string{binary}, &out, &errOut)

decode(t, out.Bytes())
if !strings.HasSuffix(out.String(), "}\n") {
t.Errorf("the output does not end with the document, so something else was written after it")
}
}

// TestABrokenInvocationIsNotARefusal holds the two codes apart.
//
// A gate that returns the same number for "this build names no release" and
// "you pointed me at a directory" reports one as the other, and record 0011 is
// the contract that says it may not.
func TestABrokenInvocationIsNotARefusal(t *testing.T) {
notABinary := filepath.Join(t.TempDir(), "not-a-binary")
if err := os.WriteFile(notABinary, []byte("this is text\n"), 0o644); err != nil {
t.Fatal(err)
}
binary := buildTheRunner(t)

for _, c := range []struct {
name string
args []string
}{
{"no arguments at all", nil},
{"two arguments", []string{binary, t.TempDir()}},
{"a file with no module table", []string{notABinary}},
{"a binary that is not there", []string{filepath.Join(t.TempDir(), "absent")}},
{"a directory rather than a binary", []string{t.TempDir()}},
} {
t.Run(c.name, func(t *testing.T) {
var out, errOut bytes.Buffer
if code := run(c.args, &out, &errOut); code != exitCannot {
t.Errorf("returned %d rather than %d", code, exitCannot)
}
if errOut.Len() == 0 {
t.Errorf("returned %d and said nothing about why", exitCannot)
}
if out.Len() != 0 {
t.Errorf("could not do its job and wrote %d byte(s) of document anyway", out.Len())
}
})
}
}

// decode reads the document back, and fails rather than skipping when it
// cannot. A document this suite could not parse is not a document that passed.
func decode(t *testing.T, data []byte) bom.Document {
t.Helper()

var document bom.Document
if err := json.Unmarshal(data, &document); err != nil {
t.Fatalf("the output is not a document anything can read: %v\n%s", err, data)
}
if document.BOMFormat != "CycloneDX" || document.SpecVersion != bom.SpecVersion {
t.Errorf("the document declares %q %q rather than CycloneDX %s", document.BOMFormat, document.SpecVersion, bom.SpecVersion)
}
return document
}

// buildTheRunner compiles this repository's runner into a temporary directory
// and returns the path.
//
// It builds rather than reading the test binary this suite is running inside.
// The test binary carries the testing packages and is not what a release ships,
// and the whole point of this file is to read the thing that is shipped.
func buildTheRunner(t *testing.T) string {
t.Helper()

name := "lab"
if runtime.GOOS == "windows" {
name += ".exe"
}
binary := filepath.Join(t.TempDir(), name)

build := exec.Command("go", "build", "-o", binary, "../lab")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("cannot build the runner: %v\n%s", err, output)
}
return binary
}
38 changes: 1 addition & 37 deletions cmd/notices/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import (
"fmt"
"io"
"os"
"runtime/debug"

"github.com/Flowfin/lab/internal/notices"
)
Expand Down Expand Up @@ -92,7 +91,7 @@ func run(args []string, out, errOut io.Writer) int {
return exitCannot
}

document := notices.Render(buildOf(info), notices.Cache{Root: cacheRoot})
document := notices.Render(notices.BuildOf(info), notices.Cache{Root: cacheRoot})
fmt.Fprint(out, document.Text())

if len(document.Refusals) > 0 {
Expand All @@ -103,38 +102,3 @@ func run(args []string, out, errOut io.Writer) int {
}
return exitClean
}

// buildOf turns what the toolchain recorded into what the render reads.
//
// THE REPLACEMENT IS CARRIED RATHER THAN FLATTENED. A replaced module is
// recorded twice by the toolchain: the module the build asked for, carrying the
// replacement, and the replacement itself. What is in the binary is the
// replacement's code, so that is what the licence has to come from, and a reader
// comparing this document against go.mod is looking for the module that was
// asked for. Both are written down.
func buildOf(info *debug.BuildInfo) notices.Build {
build := notices.Build{
Main: notices.Module{Path: info.Main.Path, Version: info.Main.Version},
}
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" {
build.Revision = setting.Value
}
}
for _, dep := range info.Deps {
if dep == nil {
continue
}
module := notices.Module{Path: dep.Path, Version: dep.Version}
if dep.Replace != nil {
module = notices.Module{
Path: dep.Replace.Path,
Version: dep.Replace.Version,
ReplacedPath: dep.Path,
ReplacedVersion: dep.Version,
}
}
build.Deps = append(build.Deps, module)
}
return build
}
Loading
Loading