diff --git a/cmd/notices/main.go b/cmd/notices/main.go new file mode 100644 index 0000000..83c9ef6 --- /dev/null +++ b/cmd/notices/main.go @@ -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 " + +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 +} diff --git a/cmd/notices/main_test.go b/cmd/notices/main_test.go new file mode 100644 index 0000000..5cdcf51 --- /dev/null +++ b/cmd/notices/main_test.go @@ -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 +} diff --git a/internal/notices/cache.go b/internal/notices/cache.go new file mode 100644 index 0000000..d850864 --- /dev/null +++ b/internal/notices/cache.go @@ -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 +} diff --git a/internal/notices/notices.go b/internal/notices/notices.go new file mode 100644 index 0000000..258c527 --- /dev/null +++ b/internal/notices/notices.go @@ -0,0 +1,344 @@ +// Package notices renders the third-party notices a binary owes whoever +// downloads it, from the module set the binary itself carries. +// +// WHY IT READS A BUILD RATHER THAN A FILE. A list of dependencies somebody +// maintains is correct on the day it is written and wrong shortly afterwards, +// and it is wrong in the direction that matters: a module present in the binary +// and absent from the list. The toolchain already records every module that went +// into a binary, inside the binary, so that record is the input here and there is +// no second list for anything to drift against. +// +// WHAT IT WILL NOT DO IS NAME A LICENCE WITHOUT CARRYING ITS TEXT. The obligation +// most licences impose is to supply the text, and a line saying which licence a +// module is under does not discharge it. A module whose text cannot be read is +// refused rather than listed with a gap, because a notices file that quietly +// omits one is worse than one that is obviously incomplete: it looks finished. +// +// WHAT IT CANNOT DO. It does not identify a licence. It reproduces whatever text +// the module shipped under a licence filename and takes no view on which licence +// that is, whether the module was entitled to offer it, or whether reproducing it +// is sufficient for that licence. Reading a name out of the text and reporting it +// as the module's licence would be a claim this package cannot support, and the +// document it renders says so where a reader will see it rather than only here. +package notices + +import ( + "fmt" + "path" + "sort" + "strings" +) + +// The properties this package can refuse. A property is the rule, named once +// here and nowhere else, so a case declaring what it expects and a refusal the +// render produced are the same string or they are not equal. It is the shape +// internal/invariants and internal/check already use rather than a third one. +const ( + // DependencyHasNoLicenceText refuses a module the binary contains whose + // licence text could not be read. Absent, unreadable and empty are one + // property because the repair is one repair: supply the text or do not + // ship the module. Splitting them would put three rows in front of a + // reader whose next action is the same in all three cases. + DependencyHasNoLicenceText = "dependency-has-no-licence-text" +) + +// A Module is one entry in the module set a binary carries. +type Module struct { + // Path is the module path as the toolchain records it. + Path string + + // Version is the version that went in. + Version string + + // ReplacedPath and ReplacedVersion are what this module stands in for, + // and they are empty for a module that replaced nothing. + // + // A replacement is carried rather than flattened because the two are + // different statements. The code in the binary is the replacement's and + // its licence is the one that travels with the binary; the module the + // build asked for is what a reader comparing this file against go.mod is + // holding. A document showing only one of them makes one of those two + // readers wrong. + ReplacedPath string + ReplacedVersion string +} + +// A Build is the module set of one binary, as that binary records it. +type Build struct { + // Main is the module the binary was built from. + Main Module + + // Revision is the commit the build was made at, empty where the build + // carried none. It is reported rather than required: a binary built from + // an archive rather than a checkout has no revision, and refusing that + // would refuse a legitimate build for a fact about how it was fetched. + Revision string + + // Deps is every module the binary contains beyond the main one. + Deps []Module +} + +// A Licence is the text one module shipped, and the filename it shipped it +// under. The filename is carried because it is evidence: a reader asking where +// a paragraph in this document came from is asking for a path, and a document +// naming only the module answers a different question. +type Licence struct { + // File is the path the text was read from, relative to the module root. + File string + + // Text is what that file held, verbatim. + Text string +} + +// A Source resolves a module to the licence it shipped. It is an interface so +// that the render can be proved against a directory a case wrote out in full, +// rather than against whichever module cache the machine running the suite +// happens to have populated. +type Source interface { + // Licence returns the text the module shipped. The error says why it + // could not be read, and it reaches the reader through the refusal rather + // than stopping the render: one unreadable module must not cost the + // document the other twenty entries. + Licence(Module) (Licence, error) +} + +// A Refusal is one rule refusing one subject. It carries the subject separately +// from the detail so that a message can never be written without it, which is +// the shape internal/invariants already carries. +type Refusal struct { + Property string + Subject string + Detail string +} + +// String leads with the subject, because somebody reading a red run is looking +// for the module to open first. +func (r Refusal) String() string { + return fmt.Sprintf("%s: %s (%s)", r.Subject, r.Detail, r.Property) +} + +// An Entry is one module as the document reports it. +type Entry struct { + Module Module + Licence Licence +} + +// A Document is the rendered notices and what producing it refused. +type Document struct { + // Build is what the document is about. + Build Build + + // Entries is one per dependency whose licence text was read, in module + // path order. + Entries []Entry + + // Refusals is every module whose text was not, in the same order. + Refusals []Refusal +} + +// Properties returns the set of properties this document refused, which is what +// a case declares and what the harness compares. A property refused twice is one +// entry: a verdict is a set. +func (d Document) Properties() []string { + seen := make(map[string]bool, len(d.Refusals)) + var props []string + for _, refusal := range d.Refusals { + if !seen[refusal.Property] { + seen[refusal.Property] = true + props = append(props, refusal.Property) + } + } + return props +} + +// Render reads every dependency's licence out of the source and returns the +// document, with a refusal for each module whose text could not be read. +// +// IT SORTS BY MODULE PATH AND CARRIES NO CLOCK. The bytes this produces are a +// function of the build and of the texts the modules shipped, and of nothing +// else, so two runs from one tag produce one file. A document carrying the time +// it was made would differ between two such runs, and then a checksum over it +// could no longer separate a release built from different source from one built +// twice. +func Render(build Build, source Source) Document { + document := Document{Build: build} + + deps := append([]Module(nil), build.Deps...) + sort.Slice(deps, func(i, j int) bool { + if deps[i].Path != deps[j].Path { + return deps[i].Path < deps[j].Path + } + return deps[i].Version < deps[j].Version + }) + + for _, module := range deps { + licence, err := source.Licence(module) + if err != nil { + document.Refusals = append(document.Refusals, Refusal{ + Property: DependencyHasNoLicenceText, + Subject: module.Describe(), + Detail: fmt.Sprintf("the binary contains it and its licence text could not be read (%v), so this document cannot supply the text that licence asks to be supplied", + err), + }) + continue + } + document.Entries = append(document.Entries, Entry{Module: module, Licence: licence}) + } + return document +} + +// Describe is how a module is named in a refusal and in the document. A +// replacement says both halves, because a reader holding go.mod is looking for +// the module that was asked for and a reader asking what is in the binary is +// looking for the one that arrived. +func (m Module) Describe() string { + if m.ReplacedPath == "" { + return m.Path + "@" + m.Version + } + return fmt.Sprintf("%s@%s, which replaces %s@%s", m.Path, m.Version, m.ReplacedPath, m.ReplacedVersion) +} + +// Text renders the notices document. +// +// THE ZERO-DEPENDENCY CASE IS A SENTENCE AND NOT AN EMPTY FILE. A binary +// containing no third-party module still owes its recipient the statement that +// there is nothing to disclose, and an empty file cannot be told from a run that +// failed to write one. +func (d Document) Text() string { + var out strings.Builder + + out.WriteString("# Third-party notices\n\n") + fmt.Fprintf(&out, "These notices are for %s.\n", d.Build.Main.Describe()) + if d.Build.Revision != "" { + fmt.Fprintf(&out, "Built at revision %s.\n", d.Build.Revision) + } else { + out.WriteString("The build carried no revision, so this document names none.\n") + } + out.WriteString("\n") + out.WriteString(preamble) + out.WriteString("\n") + + if len(d.Entries) == 0 && len(d.Refusals) == 0 { + out.WriteString("## Nothing to disclose\n\n") + out.WriteString("This binary contains no third-party module. That is a result this run\nproduced rather than a section nobody filled in.\n") + return out.String() + } + + if len(d.Entries) > 0 { + out.WriteString("## What the binary contains\n\n") + fmt.Fprintf(&out, "%s, in module path order.\n\n", plural(len(d.Entries), "module", "modules")) + } + for _, entry := range d.Entries { + fmt.Fprintf(&out, "### %s\n\n", entry.Module.Describe()) + fmt.Fprintf(&out, "The text below is %s as that module shipped it.\n\n", entry.Licence.File) + out.WriteString(fence(entry.Licence.Text)) + out.WriteString("\n") + } + + if len(d.Refusals) > 0 { + out.WriteString("## What this document could not supply\n\n") + out.WriteString("Each line below is a module the binary contains whose licence text was not\nread. This document is incomplete by exactly these entries.\n\n") + for _, refusal := range d.Refusals { + fmt.Fprintf(&out, "- %s\n", refusal.String()) + } + } + return out.String() +} + +// preamble is the part of the document that is the same whatever the build. It +// is here rather than inline so that the sentence bounding what this package +// claims sits next to the code that could not make a larger claim. +const preamble = `This file is generated from the module set the binary records, and not from a +list anybody maintains. What it lists is every module the binary contains, +the version that went in, and the licence text that module shipped, reproduced +in full because supplying the text is what most licences ask for and a link +is not a copy. + +It does not say which licence a module is under. It reproduces the file the +module shipped and takes no view on what that file is, which is a judgement no +reading of the text makes reliably. Anybody who needs that answer reads the +text below rather than a label this document would have guessed. +` + +// fence wraps a licence text so that it survives being read as a document. The +// text is somebody else's and is reproduced verbatim, so the fence is chosen to +// be longer than any run of backticks inside it rather than assumed to be +// three: a licence carrying a fenced block would otherwise end the block early +// and the rest of it would be read as prose. +func fence(text string) string { + longest, current := 0, 0 + for _, r := range text { + if r != '`' { + current = 0 + continue + } + current++ + longest = max(longest, current) + } + marker := strings.Repeat("`", max(3, longest+1)) + + if !strings.HasSuffix(text, "\n") { + text += "\n" + } + return marker + "text\n" + text + marker + "\n" +} + +// plural writes a count with the word that agrees with it. A heading reading +// "1 modules" is the kind of thing a reader stops on, and stopping is attention +// spent on the document instead of on what it says. +func plural(n int, one, many string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, one) + } + return fmt.Sprintf("%d %s", n, many) +} + +// EscapePath is the module cache's spelling of a module path. The cache cannot +// use the path as written, because two module paths differing only in case are +// different modules and a case-insensitive filesystem would collapse them, so +// every upper-case letter is written as an exclamation mark and its lower-case +// form. +// +// It is here rather than in the cache reader because a case proves it, and the +// module path of this very repository carries a capital letter, so the spelling +// is not an edge somebody invented for a test. +func EscapePath(modulePath string) string { + var out strings.Builder + for _, r := range modulePath { + if r >= 'A' && r <= 'Z' { + out.WriteByte('!') + out.WriteRune(r + ('a' - 'A')) + continue + } + out.WriteRune(r) + } + return out.String() +} + +// LicenceFilenames is what a module's licence is looked for under, in the order +// it is looked for. The list is short and conventional on purpose: a reader +// asking why a module was refused wants to know which names were tried, and a +// longer list would make the answer to that question longer without making the +// search meaningfully better. +var LicenceFilenames = []string{ + "LICENSE", + "LICENSE.md", + "LICENSE.txt", + "LICENCE", + "LICENCE.md", + "LICENCE.txt", + "COPYING", + "COPYING.md", +} + +// ModuleDir is where the cache holds one module, under the cache root. +func ModuleDir(module Module) string { + return EscapePath(module.Path) + "@" + module.Version +} + +// LicencePath joins a module directory and one of the filenames above, in the +// slash-separated form the cache uses, so that a message naming it reads the +// same on every platform the runner is built for. +func LicencePath(module Module, filename string) string { + return path.Join(ModuleDir(module), filename) +} diff --git a/internal/notices/notices_test.go b/internal/notices/notices_test.go new file mode 100644 index 0000000..21b6438 --- /dev/null +++ b/internal/notices/notices_test.go @@ -0,0 +1,335 @@ +// The harness these rules are proved with. +// +// A case is a directory under testdata/notices/. It holds the module set a +// binary recorded, as a file rather than as a struct assembled at run time, and +// the module cache the texts are read out of, as directories in the repository +// rather than as whichever modules this machine has downloaded. It holds the +// whole document the render should have produced and the whole set of +// properties it should have refused. +// +// The layout of a case: +// +// testdata/notices//build the module set, one line per fact +// testdata/notices//cache/ the module cache, may be absent +// testdata/notices//expected the document, byte for byte +// testdata/notices//expected-refusals one property per line, may be +// empty +// testdata/notices//near-neighbour the case that differs by the +// smallest legal change, required +// of a case that refuses +// +// It is the shape internal/contexts and the record checks are proved with rather +// than a fourth one, because a reader who has understood one harness here should +// not have to learn another. +package notices + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +// casesDir is where the cases live. Record 0002 puts the runner's own fixtures +// at the root of the tree rather than beside the package, so the path climbs out +// of internal/notices. +const casesDir = "../../testdata/notices" + +// A caseInput is one case as its files declare it. +type caseInput struct { + name string + build Build + cache Cache + expected string + refusals []string + neighbour string +} + +// TestEveryCaseIsRenderedAsItsFilesDeclare holds the render to both halves of +// every case: the properties it refused and the bytes it produced. +// +// The document is compared in full rather than by a substring. A test asserting +// that the text contains a module path passes on a document that lost the +// licence underneath it, and losing the text while keeping the name is the exact +// failure this package exists to prevent. +func TestEveryCaseIsRenderedAsItsFilesDeclare(t *testing.T) { + cases := readCases(t) + if len(cases) == 0 { + t.Fatalf("no cases under %s, so this suite proved nothing", casesDir) + } + + for _, input := range cases { + t.Run(input.name, func(t *testing.T) { + document := Render(input.build, input.cache) + + got := document.Properties() + sort.Strings(got) + want := append([]string(nil), input.refusals...) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Errorf("refused %v, and the case declares %v", got, want) + for _, refusal := range document.Refusals { + t.Logf(" %s", refusal) + } + } + + if text := document.Text(); text != input.expected { + t.Errorf("the document does not match the case, byte for byte") + t.Logf("produced:\n%s", text) + t.Logf("declared:\n%s", input.expected) + } + }) + } + t.Logf("%d case(s) read from %s", len(cases), casesDir) +} + +// TestACaseThatRefusesNamesANeighbourThatDoesNot is the near-miss discipline. +// +// A case that refuses proves only that something in it was refused. What proves +// the rule bites for the reason it names is the neighbour: the same case with +// the one thing repaired, refusing nothing. Without it a rule that refused every +// module it read would pass every case here. +func TestACaseThatRefusesNamesANeighbourThatDoesNot(t *testing.T) { + cases := readCases(t) + byName := make(map[string]caseInput, len(cases)) + for _, input := range cases { + byName[input.name] = input + } + + checked := 0 + for _, input := range cases { + if len(input.refusals) == 0 { + continue + } + if input.neighbour == "" { + t.Errorf("%s refuses %v and names no near neighbour", input.name, input.refusals) + continue + } + neighbour, ok := byName[input.neighbour] + if !ok { + t.Errorf("%s names the near neighbour %s, which is not a case", input.name, input.neighbour) + continue + } + if properties := Render(neighbour.build, neighbour.cache).Properties(); len(properties) != 0 { + t.Errorf("%s is the near neighbour of %s and refuses %v, so it proves nothing about why %s was refused", + neighbour.name, input.name, properties, input.name) + continue + } + checked++ + } + t.Logf("%d refusing case(s) proved against a neighbour that passes", checked) +} + +// TestADependencyAddedToTheBuildIsListed is the leg issue #37 names in its own +// words: a run against a tree with a dependency added produces a notices file +// that lists it. +// +// It is written as one render of two builds rather than as two cases compared by +// eye, because what it has to hold is the difference. A document that named the +// module whatever the build said would pass a case that only ever saw a build +// with a dependency in it. +// +// THE BOUND, and it is why the command's own suite exists as well. The build +// here is a module set constructed in this test, not one read out of a compiled +// binary, so what this proves is the render. That the module table inside a real +// binary reaches this render is what cmd/notices proves, against a binary it +// builds. +func TestADependencyAddedToTheBuildIsListed(t *testing.T) { + cases := readCases(t) + byName := make(map[string]caseInput, len(cases)) + for _, input := range cases { + byName[input.name] = input + } + + without, ok := byName["a-build-with-no-dependencies"] + if !ok { + t.Fatalf("the case a-build-with-no-dependencies is not in %s", casesDir) + } + with, ok := byName["a-build-with-one-dependency"] + if !ok { + t.Fatalf("the case a-build-with-one-dependency is not in %s", casesDir) + } + if len(with.build.Deps) != 1 { + t.Fatalf("a-build-with-one-dependency carries %d dependencies", len(with.build.Deps)) + } + added := with.build.Deps[0] + + before := Render(without.build, without.cache).Text() + if strings.Contains(before, added.Path) { + t.Fatalf("the build with no dependencies already names %s, so the comparison below proves nothing", added.Path) + } + + build := without.build + build.Deps = []Module{added} + after := Render(build, with.cache) + + if properties := after.Properties(); len(properties) != 0 { + t.Fatalf("adding %s refused %v", added.Describe(), properties) + } + if !strings.Contains(after.Text(), added.Describe()) { + t.Errorf("the document does not name %s", added.Describe()) + } + + licence, err := with.cache.Licence(added) + if err != nil { + t.Fatalf("the case's own cache cannot supply %s: %v", added.Describe(), err) + } + if !strings.Contains(after.Text(), strings.TrimSpace(licence.Text)) { + t.Errorf("the document names %s and does not carry its licence text, which is the half a link would also have failed to supply", added.Path) + } + t.Logf("adding %s moved the document from %d to %d byte(s) and carried its licence text", added.Describe(), len(before), len(after.Text())) +} + +// TestTheDocumentIsAFunctionOfTheBuildAlone holds the render to producing one +// file from one build. +// +// The release milestone asks for two runs from one tag to produce identical +// checksums, and a document carrying the time it was made would defeat that +// wherever it is attached. A clock is the easy thing to add to a generated file +// and the hard thing to notice afterwards, because a document with yesterday's +// date in it looks correct. +func TestTheDocumentIsAFunctionOfTheBuildAlone(t *testing.T) { + for _, input := range readCases(t) { + first := Render(input.build, input.cache).Text() + second := Render(input.build, input.cache).Text() + if first != second { + t.Errorf("%s renders two different documents from one build", input.name) + } + } +} + +// TestTheModuleCacheSpellingSurvivesACapital holds the escaping to the case the +// module path of this repository is an instance of. +// +// It is a unit rather than a case because the property is about a string, and a +// case proving it would need a directory whose name differs from the module path +// by exactly the substitution under test, which is the thing a reader would have +// to check by eye. +func TestTheModuleCacheSpellingSurvivesACapital(t *testing.T) { + for _, pair := range []struct{ in, want string }{ + {"github.com/Flowfin/lab", "github.com/!flowfin/lab"}, + {"github.com/flowfin/lab", "github.com/flowfin/lab"}, + {"ALLCAPS", "!a!l!l!c!a!p!s"}, + {"", ""}, + } { + if got := EscapePath(pair.in); got != pair.want { + t.Errorf("EscapePath(%q) is %q, and the cache spells it %q", pair.in, got, pair.want) + } + } +} + +// readCases reads every case directory, and fails rather than skipping when one +// is malformed. A case the harness could not read is not a case that passed. +func readCases(t *testing.T) []caseInput { + t.Helper() + + entries, err := os.ReadDir(casesDir) + if err != nil { + t.Fatalf("cannot read %s: %v", casesDir, err) + } + + var cases []caseInput + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(casesDir, entry.Name()) + + build, err := readBuild(filepath.Join(dir, "build")) + if err != nil { + t.Fatalf("%s: %v", entry.Name(), err) + } + expected, err := os.ReadFile(filepath.Join(dir, "expected")) + if err != nil { + t.Fatalf("%s: %v", entry.Name(), err) + } + cases = append(cases, caseInput{ + name: entry.Name(), + build: build, + cache: Cache{Root: filepath.Join(dir, "cache")}, + expected: string(expected), + refusals: readLines(t, filepath.Join(dir, "expected-refusals")), + neighbour: readOptional(t, filepath.Join(dir, "near-neighbour")), + }) + } + return cases +} + +// readBuild reads a case's module set. One fact per line, so that a case is +// read as a file rather than as a format somebody has to decode: +// +// main +// revision +// dep +// dep replaces +func readBuild(path string) (Build, error) { + text, err := os.ReadFile(path) + if err != nil { + return Build{}, err + } + + var build Build + for number, line := range strings.Split(string(text), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + switch { + case fields[0] == "main" && len(fields) == 3: + build.Main = Module{Path: fields[1], Version: fields[2]} + case fields[0] == "revision" && len(fields) == 2: + build.Revision = fields[1] + case fields[0] == "dep" && len(fields) == 3: + build.Deps = append(build.Deps, Module{Path: fields[1], Version: fields[2]}) + case fields[0] == "dep" && len(fields) == 6 && fields[3] == "replaces": + build.Deps = append(build.Deps, Module{ + Path: fields[1], Version: fields[2], + ReplacedPath: fields[4], ReplacedVersion: fields[5], + }) + default: + // The message names the line rather than only the file, because + // a harness that says a fixture is wrong and not where sends a + // reader to look through it. + return Build{}, fmt.Errorf("%s: line %d is not a fact this harness reads: %s", path, number+1, line) + } + } + return build, nil +} + +// readLines reads a file of one entry per line. An absent file is an empty list +// rather than a failure, because most cases refuse nothing and a file holding +// nothing is noise in every one of them. +func readLines(t *testing.T, path string) []string { + t.Helper() + + text, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatalf("cannot read %s: %v", path, err) + } + + var lines []string + for _, line := range strings.Split(string(text), "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + return lines +} + +// readOptional reads a one-line file, or the empty string where there is none. +func readOptional(t *testing.T, path string) string { + t.Helper() + + lines := readLines(t, path) + if len(lines) == 0 { + return "" + } + return lines[0] +} diff --git a/testdata/notices/a-build-with-no-dependencies/build b/testdata/notices/a-build-with-no-dependencies/build new file mode 100644 index 0000000..fbd8ec8 --- /dev/null +++ b/testdata/notices/a-build-with-no-dependencies/build @@ -0,0 +1,2 @@ +main github.com/Flowfin/lab v0.0.0-devel +revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a diff --git a/testdata/notices/a-build-with-no-dependencies/expected b/testdata/notices/a-build-with-no-dependencies/expected new file mode 100644 index 0000000..a818999 --- /dev/null +++ b/testdata/notices/a-build-with-no-dependencies/expected @@ -0,0 +1,20 @@ +# Third-party notices + +These notices are for github.com/Flowfin/lab@v0.0.0-devel. +Built at revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a. + +This file is generated from the module set the binary records, and not from a +list anybody maintains. What it lists is every module the binary contains, +the version that went in, and the licence text that module shipped, reproduced +in full because supplying the text is what most licences ask for and a link +is not a copy. + +It does not say which licence a module is under. It reproduces the file the +module shipped and takes no view on what that file is, which is a judgement no +reading of the text makes reliably. Anybody who needs that answer reads the +text below rather than a label this document would have guessed. + +## Nothing to disclose + +This binary contains no third-party module. That is a result this run +produced rather than a section nobody filled in. diff --git a/testdata/notices/a-build-with-one-dependency/build b/testdata/notices/a-build-with-one-dependency/build new file mode 100644 index 0000000..f66f870 --- /dev/null +++ b/testdata/notices/a-build-with-one-dependency/build @@ -0,0 +1,3 @@ +main github.com/Flowfin/lab v0.0.0-devel +revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a +dep example.com/widget v1.2.3 diff --git a/testdata/notices/a-build-with-one-dependency/cache/example.com/widget@v1.2.3/LICENSE b/testdata/notices/a-build-with-one-dependency/cache/example.com/widget@v1.2.3/LICENSE new file mode 100644 index 0000000..06a919b --- /dev/null +++ b/testdata/notices/a-build-with-one-dependency/cache/example.com/widget@v1.2.3/LICENSE @@ -0,0 +1,6 @@ +Widget Licence 1.0 + +Permission to use, copy and distribute this widget is granted, provided that +this notice is reproduced in full in every copy that is distributed. + +The widget is provided as it is, with no warranty of any kind. diff --git a/testdata/notices/a-build-with-one-dependency/expected b/testdata/notices/a-build-with-one-dependency/expected new file mode 100644 index 0000000..2255c2b --- /dev/null +++ b/testdata/notices/a-build-with-one-dependency/expected @@ -0,0 +1,33 @@ +# Third-party notices + +These notices are for github.com/Flowfin/lab@v0.0.0-devel. +Built at revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a. + +This file is generated from the module set the binary records, and not from a +list anybody maintains. What it lists is every module the binary contains, +the version that went in, and the licence text that module shipped, reproduced +in full because supplying the text is what most licences ask for and a link +is not a copy. + +It does not say which licence a module is under. It reproduces the file the +module shipped and takes no view on what that file is, which is a judgement no +reading of the text makes reliably. Anybody who needs that answer reads the +text below rather than a label this document would have guessed. + +## What the binary contains + +1 module, in module path order. + +### example.com/widget@v1.2.3 + +The text below is example.com/widget@v1.2.3/LICENSE as that module shipped it. + +```text +Widget Licence 1.0 + +Permission to use, copy and distribute this widget is granted, provided that +this notice is reproduced in full in every copy that is distributed. + +The widget is provided as it is, with no warranty of any kind. +``` + diff --git a/testdata/notices/a-dependency-that-was-replaced/build b/testdata/notices/a-dependency-that-was-replaced/build new file mode 100644 index 0000000..257a4b6 --- /dev/null +++ b/testdata/notices/a-dependency-that-was-replaced/build @@ -0,0 +1,3 @@ +main github.com/Flowfin/lab v0.0.0-devel +revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a +dep example.com/widget v1.2.3 replaces example.com/gadget v0.9.0 diff --git a/testdata/notices/a-dependency-that-was-replaced/cache/example.com/widget@v1.2.3/LICENSE b/testdata/notices/a-dependency-that-was-replaced/cache/example.com/widget@v1.2.3/LICENSE new file mode 100644 index 0000000..fe77c3b --- /dev/null +++ b/testdata/notices/a-dependency-that-was-replaced/cache/example.com/widget@v1.2.3/LICENSE @@ -0,0 +1,4 @@ +Widget Licence 1.0 + +Permission to use, copy and distribute this widget is granted, provided that +this notice is reproduced in full in every copy that is distributed. diff --git a/testdata/notices/a-dependency-that-was-replaced/expected b/testdata/notices/a-dependency-that-was-replaced/expected new file mode 100644 index 0000000..d91af75 --- /dev/null +++ b/testdata/notices/a-dependency-that-was-replaced/expected @@ -0,0 +1,31 @@ +# Third-party notices + +These notices are for github.com/Flowfin/lab@v0.0.0-devel. +Built at revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a. + +This file is generated from the module set the binary records, and not from a +list anybody maintains. What it lists is every module the binary contains, +the version that went in, and the licence text that module shipped, reproduced +in full because supplying the text is what most licences ask for and a link +is not a copy. + +It does not say which licence a module is under. It reproduces the file the +module shipped and takes no view on what that file is, which is a judgement no +reading of the text makes reliably. Anybody who needs that answer reads the +text below rather than a label this document would have guessed. + +## What the binary contains + +1 module, in module path order. + +### example.com/widget@v1.2.3, which replaces example.com/gadget@v0.9.0 + +The text below is example.com/widget@v1.2.3/LICENSE as that module shipped it. + +```text +Widget Licence 1.0 + +Permission to use, copy and distribute this widget is granted, provided that +this notice is reproduced in full in every copy that is distributed. +``` + diff --git a/testdata/notices/a-dependency-whose-licence-file-is-empty/build b/testdata/notices/a-dependency-whose-licence-file-is-empty/build new file mode 100644 index 0000000..f66f870 --- /dev/null +++ b/testdata/notices/a-dependency-whose-licence-file-is-empty/build @@ -0,0 +1,3 @@ +main github.com/Flowfin/lab v0.0.0-devel +revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a +dep example.com/widget v1.2.3 diff --git a/testdata/notices/a-dependency-whose-licence-file-is-empty/cache/example.com/widget@v1.2.3/LICENSE b/testdata/notices/a-dependency-whose-licence-file-is-empty/cache/example.com/widget@v1.2.3/LICENSE new file mode 100644 index 0000000..8208202 --- /dev/null +++ b/testdata/notices/a-dependency-whose-licence-file-is-empty/cache/example.com/widget@v1.2.3/LICENSE @@ -0,0 +1,3 @@ + + + diff --git a/testdata/notices/a-dependency-whose-licence-file-is-empty/expected b/testdata/notices/a-dependency-whose-licence-file-is-empty/expected new file mode 100644 index 0000000..14986d6 --- /dev/null +++ b/testdata/notices/a-dependency-whose-licence-file-is-empty/expected @@ -0,0 +1,22 @@ +# Third-party notices + +These notices are for github.com/Flowfin/lab@v0.0.0-devel. +Built at revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a. + +This file is generated from the module set the binary records, and not from a +list anybody maintains. What it lists is every module the binary contains, +the version that went in, and the licence text that module shipped, reproduced +in full because supplying the text is what most licences ask for and a link +is not a copy. + +It does not say which licence a module is under. It reproduces the file the +module shipped and takes no view on what that file is, which is a judgement no +reading of the text makes reliably. Anybody who needs that answer reads the +text below rather than a label this document would have guessed. + +## What this document could not supply + +Each line below is a module the binary contains whose licence text was not +read. This document is incomplete by exactly these entries. + +- example.com/widget@v1.2.3: the binary contains it and its licence text could not be read (example.com/widget@v1.2.3/LICENSE is present and holds no text, so there is nothing to reproduce), so this document cannot supply the text that licence asks to be supplied (dependency-has-no-licence-text) diff --git a/testdata/notices/a-dependency-whose-licence-file-is-empty/expected-refusals b/testdata/notices/a-dependency-whose-licence-file-is-empty/expected-refusals new file mode 100644 index 0000000..cdd42ac --- /dev/null +++ b/testdata/notices/a-dependency-whose-licence-file-is-empty/expected-refusals @@ -0,0 +1 @@ +dependency-has-no-licence-text diff --git a/testdata/notices/a-dependency-whose-licence-file-is-empty/near-neighbour b/testdata/notices/a-dependency-whose-licence-file-is-empty/near-neighbour new file mode 100644 index 0000000..e30bc75 --- /dev/null +++ b/testdata/notices/a-dependency-whose-licence-file-is-empty/near-neighbour @@ -0,0 +1 @@ +a-build-with-one-dependency diff --git a/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/build b/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/build new file mode 100644 index 0000000..f66f870 --- /dev/null +++ b/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/build @@ -0,0 +1,3 @@ +main github.com/Flowfin/lab v0.0.0-devel +revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a +dep example.com/widget v1.2.3 diff --git a/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/expected b/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/expected new file mode 100644 index 0000000..f919e8f --- /dev/null +++ b/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/expected @@ -0,0 +1,22 @@ +# Third-party notices + +These notices are for github.com/Flowfin/lab@v0.0.0-devel. +Built at revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a. + +This file is generated from the module set the binary records, and not from a +list anybody maintains. What it lists is every module the binary contains, +the version that went in, and the licence text that module shipped, reproduced +in full because supplying the text is what most licences ask for and a link +is not a copy. + +It does not say which licence a module is under. It reproduces the file the +module shipped and takes no view on what that file is, which is a judgement no +reading of the text makes reliably. Anybody who needs that answer reads the +text below rather than a label this document would have guessed. + +## What this document could not supply + +Each line below is a module the binary contains whose licence text was not +read. This document is incomplete by exactly these entries. + +- example.com/widget@v1.2.3: the binary contains it and its licence text could not be read (no licence file under example.com/widget@v1.2.3; the names tried were LICENSE, LICENSE.md, LICENSE.txt, LICENCE, LICENCE.md, LICENCE.txt, COPYING, COPYING.md), so this document cannot supply the text that licence asks to be supplied (dependency-has-no-licence-text) diff --git a/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/expected-refusals b/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/expected-refusals new file mode 100644 index 0000000..cdd42ac --- /dev/null +++ b/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/expected-refusals @@ -0,0 +1 @@ +dependency-has-no-licence-text diff --git a/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/near-neighbour b/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/near-neighbour new file mode 100644 index 0000000..e30bc75 --- /dev/null +++ b/testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/near-neighbour @@ -0,0 +1 @@ +a-build-with-one-dependency diff --git a/testdata/notices/a-module-path-that-carries-a-capital/build b/testdata/notices/a-module-path-that-carries-a-capital/build new file mode 100644 index 0000000..d897aec --- /dev/null +++ b/testdata/notices/a-module-path-that-carries-a-capital/build @@ -0,0 +1,3 @@ +main github.com/Flowfin/lab v0.0.0-devel +revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a +dep github.com/Flowfin/widget v0.1.0 diff --git a/testdata/notices/a-module-path-that-carries-a-capital/cache/github.com/!flowfin/widget@v0.1.0/LICENCE.md b/testdata/notices/a-module-path-that-carries-a-capital/cache/github.com/!flowfin/widget@v0.1.0/LICENCE.md new file mode 100644 index 0000000..f5bf03e --- /dev/null +++ b/testdata/notices/a-module-path-that-carries-a-capital/cache/github.com/!flowfin/widget@v0.1.0/LICENCE.md @@ -0,0 +1,8 @@ +Widget Licence 1.0 + +Reproduce this notice. The clause below is fenced in the original, which is why +this case exists: + +``` +Distribute the widget with this text and nothing is owed beyond it. +``` diff --git a/testdata/notices/a-module-path-that-carries-a-capital/expected b/testdata/notices/a-module-path-that-carries-a-capital/expected new file mode 100644 index 0000000..4f0546c --- /dev/null +++ b/testdata/notices/a-module-path-that-carries-a-capital/expected @@ -0,0 +1,35 @@ +# Third-party notices + +These notices are for github.com/Flowfin/lab@v0.0.0-devel. +Built at revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a. + +This file is generated from the module set the binary records, and not from a +list anybody maintains. What it lists is every module the binary contains, +the version that went in, and the licence text that module shipped, reproduced +in full because supplying the text is what most licences ask for and a link +is not a copy. + +It does not say which licence a module is under. It reproduces the file the +module shipped and takes no view on what that file is, which is a judgement no +reading of the text makes reliably. Anybody who needs that answer reads the +text below rather than a label this document would have guessed. + +## What the binary contains + +1 module, in module path order. + +### github.com/Flowfin/widget@v0.1.0 + +The text below is github.com/!flowfin/widget@v0.1.0/LICENCE.md as that module shipped it. + +````text +Widget Licence 1.0 + +Reproduce this notice. The clause below is fenced in the original, which is why +this case exists: + +``` +Distribute the widget with this text and nothing is owed beyond it. +``` +```` + diff --git a/testdata/notices/a-module-path-that-climbs-out-of-the-cache/build b/testdata/notices/a-module-path-that-climbs-out-of-the-cache/build new file mode 100644 index 0000000..6f91822 --- /dev/null +++ b/testdata/notices/a-module-path-that-climbs-out-of-the-cache/build @@ -0,0 +1,3 @@ +main github.com/Flowfin/lab v0.0.0-devel +revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a +dep ../../elsewhere v1.0.0 diff --git a/testdata/notices/a-module-path-that-climbs-out-of-the-cache/expected b/testdata/notices/a-module-path-that-climbs-out-of-the-cache/expected new file mode 100644 index 0000000..1b017ed --- /dev/null +++ b/testdata/notices/a-module-path-that-climbs-out-of-the-cache/expected @@ -0,0 +1,22 @@ +# Third-party notices + +These notices are for github.com/Flowfin/lab@v0.0.0-devel. +Built at revision 8f3c1d9a2b7e4f60c5d8a1b3e6f902478c1d5e2a. + +This file is generated from the module set the binary records, and not from a +list anybody maintains. What it lists is every module the binary contains, +the version that went in, and the licence text that module shipped, reproduced +in full because supplying the text is what most licences ask for and a link +is not a copy. + +It does not say which licence a module is under. It reproduces the file the +module shipped and takes no view on what that file is, which is a judgement no +reading of the text makes reliably. Anybody who needs that answer reads the +text below rather than a label this document would have guessed. + +## What this document could not supply + +Each line below is a module the binary contains whose licence text was not +read. This document is incomplete by exactly these entries. + +- ../../elsewhere@v1.0.0: the binary contains it and its licence text could not be read (the module path "../../elsewhere" does not name a directory under the cache root, so nothing there is this module's licence), so this document cannot supply the text that licence asks to be supplied (dependency-has-no-licence-text) diff --git a/testdata/notices/a-module-path-that-climbs-out-of-the-cache/expected-refusals b/testdata/notices/a-module-path-that-climbs-out-of-the-cache/expected-refusals new file mode 100644 index 0000000..cdd42ac --- /dev/null +++ b/testdata/notices/a-module-path-that-climbs-out-of-the-cache/expected-refusals @@ -0,0 +1 @@ +dependency-has-no-licence-text diff --git a/testdata/notices/a-module-path-that-climbs-out-of-the-cache/near-neighbour b/testdata/notices/a-module-path-that-climbs-out-of-the-cache/near-neighbour new file mode 100644 index 0000000..e30bc75 --- /dev/null +++ b/testdata/notices/a-module-path-that-climbs-out-of-the-cache/near-neighbour @@ -0,0 +1 @@ +a-build-with-one-dependency