From d1af87a83a524eccf82c683072306d5cba469da0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 18:31:07 +0200 Subject: [PATCH] =?UTF-8?q?Leave=20a=20narrowed=20build=20only=20its=20nam?= =?UTF-8?q?ed=20paths,=20manifest=20and=20locks,=20in=20one=20function=20f?= =?UTF-8?q?or=20both=20build=20modules=20(issue=201758,=20ADR=200499=20?= =?UTF-8?q?=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/buildstep/narrow.go | 69 +++++++++++++++++++++++++++++++++++++ go/buildstep/narrow_test.go | 67 +++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 go/buildstep/narrow.go create mode 100644 go/buildstep/narrow_test.go diff --git a/go/buildstep/narrow.go b/go/buildstep/narrow.go new file mode 100644 index 0000000..9bc6e00 --- /dev/null +++ b/go/buildstep/narrow.go @@ -0,0 +1,69 @@ +package buildstep + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +// narrowKept are the files of a module's directory a narrowed build keeps beside its named paths: its manifest and +// its lock files. +var narrowKept = []string{"module.json", "package.json", "package-lock.json", "go.mod", "go.sum"} + +// Narrow leaves in the fetched tree only what a build of an artifact that names its paths may see (novox/hq ADR 0499 +// clause 4): r.Dir's named paths, its manifest and lock files, and .git; everything else is removed, so a file outside +// them cannot change what is built without changing its key. A named path absent at the tree is an error naming it, +// and nothing is removed then. With no paths in r it changes nothing. Both build modules' fetch steps call it. +func Narrow(tree string, r FetchRequest) error { + if len(r.Paths) == 0 { + return nil + } + keep := map[string]bool{".git": true} + for _, p := range r.Paths { + rel := path.Join(r.Dir, p) + if strings.HasPrefix(rel, "../") || rel == ".." || path.IsAbs(p) { + return fmt.Errorf("the named path %q leaves the module's directory", p) + } + if _, err := os.Lstat(filepath.Join(tree, filepath.FromSlash(rel))); err != nil { + return fmt.Errorf("the named path %q is not in the fetched tree: %w", p, err) + } + keep[rel] = true + } + for _, f := range narrowKept { + keep[path.Join(r.Dir, f)] = true + } + return prune(tree, "", keep) +} + +// prune removes from dir (rel within the tree) every entry that is neither kept nor on the way to one. +func prune(root, rel string, keep map[string]bool) error { + entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + return err + } + for _, e := range entries { + p := path.Join(rel, e.Name()) + if keep[p] { + continue + } + ancestor := false + for k := range keep { + if strings.HasPrefix(k, p+"/") { + ancestor = true + break + } + } + if ancestor && e.IsDir() { + if err := prune(root, p, keep); err != nil { + return err + } + continue + } + if err := os.RemoveAll(filepath.Join(root, filepath.FromSlash(p))); err != nil { + return err + } + } + return nil +} diff --git a/go/buildstep/narrow_test.go b/go/buildstep/narrow_test.go new file mode 100644 index 0000000..0af2bc8 --- /dev/null +++ b/go/buildstep/narrow_test.go @@ -0,0 +1,67 @@ +package buildstep + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func filesUnder(t *testing.T, root string) []string { + t.Helper() + var out []string + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + rel, _ := filepath.Rel(root, p) + out = append(out, filepath.ToSlash(rel)) + } + return nil + }) + sort.Strings(out) + return out +} + +// A build of an artifact that names its paths sees only those paths, its manifest and its lock files (novox/hq ADR 0499 +// clause 4): Narrow removes everything else of the fetched tree but .git; with no paths it changes nothing. +func TestNarrowLeavesOnlyTheNamedPathsTheManifestAndTheLocks(t *testing.T) { + tree := t.TempDir() + for _, f := range []string{".git/HEAD", "module.json", "package.json", "package-lock.json", "go.sum", "src/index.ts", + "src/deep/a.ts", "test/a.test.ts", "tsconfig.json", "go/delivery/x.go", "README.md", "other/module.json"} { + writeTreeFile(t, tree, f, "x") + } + if err := Narrow(tree, FetchRequest{}); err != nil { + t.Fatal(err) + } + if got := len(filesUnder(t, tree)); got != 12 { + t.Fatalf("a request without paths changed the tree: %d files", got) + } + if err := Narrow(tree, FetchRequest{Dir: ".", Paths: []string{"src", "test", "tsconfig.json"}}); err != nil { + t.Fatal(err) + } + want := ".git/HEAD,go.sum,module.json,package-lock.json,package.json,src/deep/a.ts,src/index.ts,test/a.test.ts,tsconfig.json" + if got := strings.Join(filesUnder(t, tree), ","); got != want { + t.Fatalf("the narrowed tree holds %s\nwant %s", got, want) + } +} + +func TestNarrowKeepsAModuleDirectorysPathsAndFailsOnAnAbsentOne(t *testing.T) { + tree := t.TempDir() + for _, f := range []string{".git/config", "modules/a/module.json", "modules/a/go.mod", "modules/a/src/x.ts", + "modules/a/notes.md", "modules/b/module.json", "root.txt"} { + writeTreeFile(t, tree, f, "x") + } + err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src", "gone"}}) + if err == nil || !strings.Contains(err.Error(), `"gone"`) { + t.Fatalf("an absent named path gave %v", err) + } + if len(filesUnder(t, tree)) != 7 { + t.Fatal("a refused narrowing removed files") + } + if err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src"}}); err != nil { + t.Fatal(err) + } + if got := strings.Join(filesUnder(t, tree), ","); got != ".git/config,modules/a/go.mod,modules/a/module.json,modules/a/src/x.ts" { + t.Fatalf("the narrowed tree holds %s", got) + } +}