From 74762af04541029f0916fca904e1ccd6bdd35a92 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:05:12 +0200 Subject: [PATCH] Generate the third-party notices from the module table a binary carries A binary this repository publishes owes whoever downloads it the licence of everything inside it, and most licences ask for the text rather than for a name. Nothing here produced that text, so the obligation was met by a list nobody had written yet. internal/notices renders the document from the module set the toolchain records inside the binary, and reads each module's licence text out of the module cache the build already populated. cmd/notices is the entry point the release build calls: it takes the binary and the cache root as arguments, opens no connection and asks no environment variable, so what it produced can be reproduced by hand from the two paths in its command line. The failure it prevents is a notices file that names a module and does not carry its text. A module whose licence cannot be read is refused under dependency-has-no-licence-text and named in a section of the document that says the document is incomplete by exactly those entries, rather than being dropped from a file that then reads as finished. The second failure is drift: a maintained list is correct on the day it is written and wrong once a dependency arrives, so there is no second list here for anything to drift against. The render carries no clock and sorts by module path, so two runs from one build produce one file and a checksum over it still separates a release built from different source from one built twice. Refs #37 Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- cmd/notices/main.go | 140 +++++++ cmd/notices/main_test.go | 109 ++++++ internal/notices/cache.go | 80 ++++ internal/notices/notices.go | 344 ++++++++++++++++++ internal/notices/notices_test.go | 335 +++++++++++++++++ .../a-build-with-no-dependencies/build | 2 + .../a-build-with-no-dependencies/expected | 20 + .../notices/a-build-with-one-dependency/build | 3 + .../cache/example.com/widget@v1.2.3/LICENSE | 6 + .../a-build-with-one-dependency/expected | 33 ++ .../a-dependency-that-was-replaced/build | 3 + .../cache/example.com/widget@v1.2.3/LICENSE | 4 + .../a-dependency-that-was-replaced/expected | 31 ++ .../build | 3 + .../cache/example.com/widget@v1.2.3/LICENSE | 3 + .../expected | 22 ++ .../expected-refusals | 1 + .../near-neighbour | 1 + .../build | 3 + .../expected | 22 ++ .../expected-refusals | 1 + .../near-neighbour | 1 + .../build | 3 + .../!flowfin/widget@v0.1.0/LICENCE.md | 8 + .../expected | 35 ++ .../build | 3 + .../expected | 22 ++ .../expected-refusals | 1 + .../near-neighbour | 1 + 29 files changed, 1240 insertions(+) create mode 100644 cmd/notices/main.go create mode 100644 cmd/notices/main_test.go create mode 100644 internal/notices/cache.go create mode 100644 internal/notices/notices.go create mode 100644 internal/notices/notices_test.go create mode 100644 testdata/notices/a-build-with-no-dependencies/build create mode 100644 testdata/notices/a-build-with-no-dependencies/expected create mode 100644 testdata/notices/a-build-with-one-dependency/build create mode 100644 testdata/notices/a-build-with-one-dependency/cache/example.com/widget@v1.2.3/LICENSE create mode 100644 testdata/notices/a-build-with-one-dependency/expected create mode 100644 testdata/notices/a-dependency-that-was-replaced/build create mode 100644 testdata/notices/a-dependency-that-was-replaced/cache/example.com/widget@v1.2.3/LICENSE create mode 100644 testdata/notices/a-dependency-that-was-replaced/expected create mode 100644 testdata/notices/a-dependency-whose-licence-file-is-empty/build create mode 100644 testdata/notices/a-dependency-whose-licence-file-is-empty/cache/example.com/widget@v1.2.3/LICENSE create mode 100644 testdata/notices/a-dependency-whose-licence-file-is-empty/expected create mode 100644 testdata/notices/a-dependency-whose-licence-file-is-empty/expected-refusals create mode 100644 testdata/notices/a-dependency-whose-licence-file-is-empty/near-neighbour create mode 100644 testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/build create mode 100644 testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/expected create mode 100644 testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/expected-refusals create mode 100644 testdata/notices/a-dependency-whose-licence-is-not-in-the-cache/near-neighbour create mode 100644 testdata/notices/a-module-path-that-carries-a-capital/build create mode 100644 testdata/notices/a-module-path-that-carries-a-capital/cache/github.com/!flowfin/widget@v0.1.0/LICENCE.md create mode 100644 testdata/notices/a-module-path-that-carries-a-capital/expected create mode 100644 testdata/notices/a-module-path-that-climbs-out-of-the-cache/build create mode 100644 testdata/notices/a-module-path-that-climbs-out-of-the-cache/expected create mode 100644 testdata/notices/a-module-path-that-climbs-out-of-the-cache/expected-refusals create mode 100644 testdata/notices/a-module-path-that-climbs-out-of-the-cache/near-neighbour 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