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
140 changes: 140 additions & 0 deletions cmd/notices/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Command notices renders the third-party notices for a binary this repository
// built, from the module table that binary carries.
//
// WHY THIS IS NOT A VERB OF THE RUNNER. lab reads a checkout. This reads a
// compiled artefact and a module cache, which are neither of them a checkout,
// and the release build is the only place it is ever run. 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. It is the same
// judgement cmd/contexts and cmd/pullrequest already made, and record 0002 names
// none of the three, which is the thing in this file a reader should check
// rather than accept.
//
// IT READS TWO PATHS AND NOTHING ELSE. The binary to describe and the module
// cache to read licence texts out of, both given as arguments. It asks no
// environment variable and runs no other program, so what it produced can be
// reproduced by hand from the two paths in the command line. Asking the
// toolchain where the cache is would be one more thing to have installed at the
// moment a release is being built, and the workflow already knows the answer.
//
// IT OPENS NO CONNECTION. Every licence text comes out of the cache the build
// already populated, so a notices file 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"
"runtime/debug"

"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 notices are complete in the eyes of any licence, only that every
// module the binary carries was listed with the text it shipped.
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 it says which entries.
exitRefused = 1

// exitCannot means the command could not do its job: no arguments, a
// binary it cannot read a module table out of, a cache path that is not
// there. 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: notices <binary> <module-cache-root>"

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. 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) != 2 {
fmt.Fprintf(errOut, "notices: %s\n", usage)
return exitCannot
}
binaryPath, cacheRoot := args[0], args[1]

info, err := buildinfo.ReadFile(binaryPath)
if err != nil {
fmt.Fprintf(errOut, "notices: cannot read the module table of %s: %v\n", binaryPath, err)
return exitCannot
}
if stat, err := os.Stat(cacheRoot); err != nil || !stat.IsDir() {
// A cache that is not there is a broken invocation and not a module
// with no licence. Without this the run would refuse every dependency
// under a property that names the module, and the reader would go
// looking at the modules.
fmt.Fprintf(errOut, "notices: %s is not a module cache this run can read\n", cacheRoot)
return exitCannot
}

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

if len(document.Refusals) > 0 {
for _, refusal := range document.Refusals {
fmt.Fprintf(errOut, "notices: %s\n", refusal)
}
return exitRefused
}
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
}
109 changes: 109 additions & 0 deletions cmd/notices/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// What this command's own suite is for, and it is not a second copy of
// internal/notices' 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 notices
// file describing nothing. So this builds a binary from this repository and
// reads that.
package main

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

// 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.
//
// It also asserts the disclosure sentence, because "no third-party module" and
// "the run produced nothing" are the two outcomes this whole package exists to
// keep apart.
func TestTheModuleTableOfARealBinaryReachesTheDocument(t *testing.T) {
binary := buildTheRunner(t)
cache := t.TempDir()

var out, errOut bytes.Buffer
if code := run([]string{binary, cache}, &out, &errOut); code != exitClean {
t.Fatalf("returned %d, and stderr said %q", code, errOut.String())
}

document := out.String()
if !strings.Contains(document, "github.com/Flowfin/lab") {
t.Errorf("the document does not name the module the binary was built from:\n%s", document)
}
if !strings.Contains(document, "This binary contains no third-party module.") {
t.Errorf("the document does not say that there is nothing to disclose:\n%s", document)
}
t.Logf("described %s in %d byte(s)", filepath.Base(binary), len(document))
}

// TestABrokenInvocationIsNotARefusal holds the two codes apart.
//
// A gate that returns the same number for "this module ships no licence" 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},
{"one argument", []string{binary}},
{"three arguments", []string{binary, t.TempDir(), "and one more"}},
{"a file with no module table", []string{notABinary, t.TempDir()}},
{"a binary that is not there", []string{filepath.Join(t.TempDir(), "absent"), t.TempDir()}},
{"a cache root that is not there", []string{binary, filepath.Join(t.TempDir(), "absent")}},
{"a cache root that is a file", []string{binary, notABinary}},
} {
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)
}
})
}
}

// 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
}
80 changes: 80 additions & 0 deletions internal/notices/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// The source that reads licence texts out of a module cache.
//
// The cache is where the toolchain already put every module it downloaded, so
// reading it costs no fetch and no network. That matters twice: this repository
// claims its runner opens no connection and holds that claim to a test, and a
// notices file that could only be produced with a working network is a notices
// file that cannot be produced from an archive of the build.

package notices

import (
"fmt"
"os"
"path/filepath"
"strings"
)

// A Cache reads licence texts from a directory laid out the way the module
// cache is: one directory per module and version, under the escaped module
// path.
//
// It is a directory rather than the cache itself so that a case can write one
// out in full. A test resolving against whichever modules the machine running
// the suite happens to have downloaded proves the state of that machine on the
// day it ran, not the reader.
type Cache struct {
// Root is the directory the modules are under.
Root string
}

// Licence reads the text a module shipped.
//
// It tries the conventional filenames in order and takes the first that holds
// something. A module carrying two of them is reporting the same licence twice
// in this repository's experience, and a document reproducing both would double
// its length for no reader.
func (c Cache) Licence(module Module) (Licence, error) {
dir, err := c.moduleDir(module)
if err != nil {
return Licence{}, err
}

var tried []string
for _, filename := range LicenceFilenames {
text, err := os.ReadFile(filepath.Join(dir, filename))
if err != nil {
tried = append(tried, filename)
continue
}
if strings.TrimSpace(string(text)) == "" {
return Licence{}, fmt.Errorf("%s is present and holds no text, so there is nothing to reproduce", LicencePath(module, filename))
}
return Licence{File: LicencePath(module, filename), Text: string(text)}, nil
}
return Licence{}, fmt.Errorf("no licence file under %s; the names tried were %s", ModuleDir(module), strings.Join(tried, ", "))
}

// moduleDir is where the cache holds this module, and it refuses a module path
// that does not name a directory under the root.
//
// THE INPUT IS NOT THIS REPOSITORY'S. A module path arrives from the module
// table inside a binary, which is written by whoever built it, so a path
// carrying a parent-directory element would send this reader outside the cache
// and reproduce whatever it found there as a licence. Joining and cleaning is
// what makes that possible, so the join is checked rather than trusted.
func (c Cache) moduleDir(module Module) (string, error) {
root, err := filepath.Abs(c.Root)
if err != nil {
return "", fmt.Errorf("cannot resolve the cache root %s: %w", c.Root, err)
}
dir, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(ModuleDir(module))))
if err != nil {
return "", fmt.Errorf("cannot resolve %s under the cache root: %w", ModuleDir(module), err)
}
relative, err := filepath.Rel(root, dir)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("the module path %q does not name a directory under the cache root, so nothing there is this module's licence", module.Path)
}
return dir, nil
}
Loading
Loading