From 76349ad1f7d002848e2c95caa83e12a8070c52f9 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:51:26 +0200 Subject: [PATCH] Prove a dependency added to a tree reaches the notices document Issue #37 asks for a run against a tree with a dependency added producing a notices file that lists it. Nothing in this repository measured that. The render is proved against module sets a case writes out in full, and the command's own suite reads the module table of a binary built from this repository, which has no third-party dependency and therefore exercises only the half where there is nothing to disclose. This builds one tree twice, the second differing from the first by a require line and an import, and holds the difference between the two documents. The first must not name the module and must say there is nothing to disclose; the second must name it and carry the text it shipped. The failure it prevents is a document that describes nothing while looking finished. A buildOf that reads the module table and drops what it finds produces a notices file saying the binary contains no third-party module, which is the sentence an operator would read as an answer. I proved that by deleting the line that collects them: go test -count=1 ./cmd/... ./internal/... With `build.Deps = append(build.Deps, module)` replaced by a discard, this test is the only one in the module that reddens. internal/notices stays green because its cases construct the module set themselves, and the neighbouring test in this package stays green because the binary it reads has no dependencies. The test opens no connection. The module is served out of a directory this test lays out as a module proxy, the checksum database is off, the toolchain is pinned to the installed one so a go directive is never a download, and the module cache is a temporary directory, so the licence text in the document came from this run rather than from what the machine had downloaded before it. The three variables that send a build past a proxy are emptied rather than inherited, because any of them matching this module turns the module path into a domain and fetches over https. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- cmd/notices/tree_test.go | 289 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 cmd/notices/tree_test.go diff --git a/cmd/notices/tree_test.go b/cmd/notices/tree_test.go new file mode 100644 index 0000000..8415e80 --- /dev/null +++ b/cmd/notices/tree_test.go @@ -0,0 +1,289 @@ +// What this file proves that neither of the other two suites can. +// +// internal/notices proves the render, against module sets a case wrote out in +// full. main_test.go proves that the module table inside a real binary reaches +// that render, against a binary built from this repository. Neither of them +// starts from a tree: this repository has no third-party dependency, so a build +// of it exercises the empty half only, and a module set constructed in a test is +// a statement about the test rather than about a build. +// +// Issue #37 asks for the other half in its own words - a run against a tree with +// a dependency added produces a notices file that lists it - so this builds two +// binaries from one tree, the second differing from the first by a require line +// and an import, and holds the difference between the two documents. +// +// IT OPENS NO CONNECTION, which is what made this leg look unreachable. The +// module is served out of a directory laid out as a module proxy and written by +// this test, the checksum database is off, and the toolchain is pinned to the +// one already installed, so nothing here resolves a name or fetches a version. +// The module cache the build populates is a temporary directory, so the licence +// text the document carries came from this run rather than from whatever the +// machine happened to have downloaded before it. +package main + +import ( + "archive/zip" + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// The module the tree under test depends on. +// +// It is invented rather than borrowed from the real ecosystem. A test that +// depends on something real depends on that thing staying fetchable and staying +// licensed the way it was on the day the test was written, and both of those are +// facts about somebody else's repository. +const ( + dependencyPath = "example.com/dep" + dependencyVersion = "v1.0.0" +) + +// The go directive is well below the toolchain this repository builds with on +// purpose. The tree here is built by whatever toolchain is running the suite, +// and a directive naming a version newer than that one asks the toolchain to +// fetch a different one, which is the network this file exists without. +const dependencyGoMod = "module example.com/dep\n\ngo 1.21\n" + +const dependencySource = `package dep + +// Answer is here so that the tree under test imports this module and uses it. +// A module that is required and never imported does not reach the binary, and +// then the document would be correct to leave it out. +func Answer() int { return 37 } +` + +// The licence text is written to be unmistakable in a document. What the +// assertion below has to separate is a document naming a module from a document +// carrying the text that module shipped, and a conventional licence header +// appears in enough places to make that a weak test. +const dependencyLicenceText = `Copyright 2026 the author of example.com/dep + +Permission is granted to reproduce this text in a notices document, which is +the whole of what this file exists to be reproduced by. +` + +// TestATreeWithADependencyAddedProducesADocumentThatListsIt builds one tree +// twice and compares the two documents. +// +// The comparison is the test. A tree that had the dependency from the start +// would pass against a command that lists whatever it finds and also against one +// that lists a module somebody hard-coded, and the difference between the two +// runs is what separates them. +func TestATreeWithADependencyAddedProducesADocumentThatListsIt(t *testing.T) { + workspace := t.TempDir() + proxy := writeTheModuleProxy(t, filepath.Join(workspace, "proxy")) + cache := filepath.Join(workspace, "modcache") + tree := filepath.Join(workspace, "tree") + + // The cache is created here rather than left to the build. The first of the + // two builds needs nothing, so it populates no cache, and a command told to + // read a directory that is not there refuses the invocation instead of + // describing the binary. + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + + environment := offlineEnvironment(proxy, cache) + cleanTheModuleCache(t, environment) + + writeTheTree(t, tree, false) + before, code := describe(t, buildTheTree(t, tree, environment, "before"), cache) + if code != exitClean { + t.Fatalf("describing the tree without the dependency returned %d", code) + } + if strings.Contains(before, dependencyPath) { + t.Fatalf("the tree without the dependency already names %s, so the comparison below proves nothing:\n%s", dependencyPath, before) + } + if !strings.Contains(before, "This binary contains no third-party module.") { + t.Errorf("the tree without the dependency does not say that there is nothing to disclose:\n%s", before) + } + + writeTheTree(t, tree, true) + after, code := describe(t, buildTheTree(t, tree, environment, "after"), cache) + if code != exitClean { + t.Fatalf("describing the tree with the dependency returned %d rather than %d, so the licence text was not read out of the cache the build populated", code, exitClean) + } + + named := dependencyPath + "@" + dependencyVersion + if !strings.Contains(after, named) { + t.Errorf("the document does not name %s:\n%s", named, after) + } + if !strings.Contains(after, strings.TrimSpace(dependencyLicenceText)) { + t.Errorf("the document names %s and does not carry the text it shipped, which is the half a link would also have failed to supply:\n%s", named, after) + } + t.Logf("adding %s moved the document from %d to %d byte(s) and carried its licence text", named, len(before), len(after)) +} + +// describe runs the command over one binary and returns what it wrote and the +// code it returned. +func describe(t *testing.T, binary, cache string) (string, int) { + t.Helper() + + var out, errOut bytes.Buffer + code := run([]string{binary, cache}, &out, &errOut) + if errOut.Len() > 0 { + t.Logf("the run said: %s", strings.TrimSpace(errOut.String())) + } + return out.String(), code +} + +// writeTheTree writes a module that either imports the dependency or does not. +// +// Both halves are written rather than the second being an edit of the first, so +// that what the two builds differ by is stated in one place a reader can see. +func writeTheTree(t *testing.T, dir string, withDependency bool) { + t.Helper() + + goMod := "module example.com/tree\n\ngo 1.21\n" + source := "package main\n\nfunc main() { println(\"the tree ran\") }\n" + if withDependency { + goMod = "module example.com/tree\n\ngo 1.21\n\nrequire " + dependencyPath + " " + dependencyVersion + "\n" + source = "package main\n\nimport \"" + dependencyPath + "\"\n\nfunc main() { println(dep.Answer()) }\n" + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(dir, "go.mod"), goMod) + write(t, filepath.Join(dir, "main.go"), source) +} + +// buildTheTree compiles the tree and returns the path of the binary. +func buildTheTree(t *testing.T, dir string, environment []string, name string) string { + t.Helper() + + if runtime.GOOS == "windows" { + name += ".exe" + } + binary := filepath.Join(t.TempDir(), name) + + build := exec.Command("go", "build", "-o", binary, ".") + build.Dir = dir + build.Env = environment + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("cannot build the tree under test: %v\n%s", err, output) + } + return binary +} + +// writeTheModuleProxy lays out the dependency the way a module proxy serves it +// and returns the directory to point the toolchain at. +func writeTheModuleProxy(t *testing.T, root string) string { + t.Helper() + + dir := filepath.Join(root, filepath.FromSlash(dependencyPath), "@v") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(dir, "list"), dependencyVersion+"\n") + write(t, filepath.Join(dir, dependencyVersion+".info"), `{"Version":"`+dependencyVersion+`"}`) + write(t, filepath.Join(dir, dependencyVersion+".mod"), dependencyGoMod) + + if err := os.WriteFile(filepath.Join(dir, dependencyVersion+".zip"), moduleArchive(t), 0o644); err != nil { + t.Fatal(err) + } + return root +} + +// moduleArchive builds the zip a module proxy serves, which is every file of the +// module under one directory named for the module and its version. +func moduleArchive(t *testing.T) []byte { + t.Helper() + + var buffer bytes.Buffer + archive := zip.NewWriter(&buffer) + prefix := dependencyPath + "@" + dependencyVersion + "/" + + for _, file := range []struct{ name, content string }{ + {"go.mod", dependencyGoMod}, + {"dep.go", dependencySource}, + {"LICENSE", dependencyLicenceText}, + } { + entry, err := archive.Create(prefix + file.name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte(file.content)); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +// offlineEnvironment is what the two builds run under. +// +// Every entry is here to remove one route to the network or to the machine's own +// state. The proxy is a directory. The checksum database is off, because it is a +// service and the module it would be asked about does not exist outside this +// test. The toolchain is the installed one, so a go directive is never a +// download. The workspace file is off, so a go.work above the temporary +// directory cannot pull this build into it. The module cache is a temporary +// directory, so the licence text in the document came from this run. +// +// THE THREE EMPTIED VARIABLES ARE THE ONES THAT SEND A BUILD PAST THE PROXY. +// They are inherited from whoever is running the suite, and any of them matching +// this module makes the toolchain resolve the path as a domain and fetch over +// https, which is a failure that only appears on a machine configured a +// particular way. Emptying them is how this file stops depending on that +// configuration. +func offlineEnvironment(proxy, cache string) []string { + return append(os.Environ(), + "GOPROXY="+proxyURL(proxy), + "GOSUMDB=off", + "GOPRIVATE=", + "GONOPROXY=", + "GONOSUMDB=", + "GOFLAGS=-mod=mod", + "GOMODCACHE="+cache, + "GOWORK=off", + "GOTOOLCHAIN=local", + ) +} + +// proxyURL spells a directory as the file URL the toolchain reads a proxy from. +// The leading slash is added where the path does not have one, which is every +// path on Windows and no path anywhere else. +func proxyURL(dir string) string { + slashed := filepath.ToSlash(dir) + if !strings.HasPrefix(slashed, "/") { + slashed = "/" + slashed + } + return "file://" + slashed +} + +// cleanTheModuleCache empties the temporary cache before the directory holding +// it is removed. +// +// The toolchain writes the cache read-only, and removing a read-only file is +// refused on one of the platforms this repository supports, so the cleanup the +// temporary directory does on its own fails there. This is registered after that +// cleanup and therefore runs before it. +func cleanTheModuleCache(t *testing.T, environment []string) { + t.Helper() + + t.Cleanup(func() { + clean := exec.Command("go", "clean", "-modcache") + clean.Env = environment + if output, err := clean.CombinedOutput(); err != nil { + t.Logf("cannot empty the temporary module cache: %v\n%s", err, output) + } + }) +} + +// write is os.WriteFile with the test failed rather than the error returned, +// because every caller here would do the same three lines. +func write(t *testing.T, path, content string) { + t.Helper() + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +}