Compute a module's narrowed tree in one function, so every caller gives the merge's value (issue 1758, S2, ADR 0499 §2)

delivery.NamedTree hashes the manifest digest with each named path's git object;
catalogue.Manifest.NamedPaths gives the union of the paths, only when every artifact
names some. The named: prefix keeps the value apart from a git tree id.
This commit is contained in:
2026-10-22 17:27:02 +02:00
parent dfc2c858d9
commit 0c8d0f73fa
4 changed files with 163 additions and 0 deletions
+27
View File
@@ -399,6 +399,33 @@ func (b *Build) problems(module string) []string {
return problems
}
// NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the
// paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact
// names paths; otherwise none, and the module keeps its directory's tree.
//
// **Read by the core alone** (ADR 0495 clause 1, the lead's decision L8 of R4e): the indexer and its merge preview
// (buildstep.ModuleTrees) call it. A build module's fetch step is handed the paths by the builder, from the indexer's
// kept inputs (delivery.KeptInputs2.Paths), and reads no manifest for them.
func (m Manifest) NamedPaths() (paths []string, every bool) {
if m.Build == nil || len(m.Build.Artifacts) == 0 {
return nil, false
}
seen := map[string]bool{}
for _, a := range m.Build.Artifacts {
if len(a.Paths) == 0 {
return nil, false
}
for _, p := range a.Paths {
seen[path.Clean(p)] = true
}
}
for p := range seen {
paths = append(paths, p)
}
sort.Strings(paths)
return paths, true
}
// pathsProblems is what is wrong with an artifact's `paths` (novox/hq ADR 0499 clause 1): named on a kind whose build
// source is derived or named otherwise, or a path that leaves the module's directory, is its whole directory, or is a
// pattern.
+21
View File
@@ -44,3 +44,24 @@ func TestAnArtifactNamesThePathsItIsBuiltFrom(t *testing.T) {
}
}
}
// The paths a module's tree is taken from are the union its artifacts name, cleaned and sorted, and only when every
// artifact names some (novox/hq ADR 0499 clause 2); a module with an artifact that names none, or with none at all,
// keeps its directory's tree.
func TestAModulesNamedPathsAreTheUnionWhenEveryArtifactNamesThem(t *testing.T) {
m := Manifest{Module: "a", Build: &Build{Artifacts: []Artifact{
{Name: "lib", Kind: ArtifactPackage, Paths: []string{"src/", "package.json"}},
{Name: "tools", Kind: ArtifactBundle, Paths: []string{"./src", "tools"}},
}}}
paths, every := m.NamedPaths()
if !every || strings.Join(paths, ",") != "package.json,src,tools" {
t.Fatalf("NamedPaths = %v, %v; want [package.json src tools], true", paths, every)
}
m.Build.Artifacts = append(m.Build.Artifacts, Artifact{Name: "img", Kind: ArtifactImage, From: "Dockerfile"})
if paths, every := m.NamedPaths(); every || paths != nil {
t.Fatalf("a module with an artifact naming no path gave %v, %v", paths, every)
}
if paths, every := (Manifest{Module: "b"}).NamedPaths(); every || paths != nil {
t.Fatalf("a module that builds nothing gave %v, %v", paths, every)
}
}
+47
View File
@@ -0,0 +1,47 @@
package delivery
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"sort"
"strings"
)
// NamedTreePrefix opens the tree input of a module whose every artifact names its paths, so such a value is never read
// as a git tree id and a reader of a kept Tree can tell the two apart.
const NamedTreePrefix = "named:"
// NamedTree is the tree input of the new-form fingerprint of a module whose every artifact names the paths it is built
// from (novox/hq ADR 0499 clause 2): `named:` and the hex sha256 of the lines "manifest <digest>" and, sorted,
// "path <path> <object>", joined by a newline. manifest is the module's ManifestDigest (the sha256 of its module.json's
// exact bytes, as Module.Manifest and the merge preview carry it); paths are the named paths, each clean and relative to
// the module's directory as the manifest names them (catalogue.Manifest.NamedPaths), with the git object id each has at
// the commit (a blob for a file, a tree for a directory).
//
// **One function computes it** (clause 2): the indexer, its merge preview (buildstep.ModuleTrees) and the build modules
// call this one, and no second computation exists. It refuses no path, a named path with no object (absent at the
// commit), a path that is not clean, and a manifest that is not a digest, naming them: the indexer then gives the
// module none, with that reason (clause 3).
func NamedTree(manifest string, paths map[string]string) (string, error) {
if _, err := ManifestsKey(manifest); err != nil {
return "", err
}
if len(paths) == 0 {
return "", fmt.Errorf("delivery: the module names no path, so it has no named tree")
}
lines := make([]string, 0, len(paths))
for p, object := range paths {
if p == "" || p != path.Clean(p) || strings.HasPrefix(p, "/") || p == ".." || strings.HasPrefix(p, "../") {
return "", fmt.Errorf("delivery: the named path %q is not a clean path inside the module's directory", p)
}
if object == "" {
return "", fmt.Errorf("delivery: the named path %q has no git object at the commit", p)
}
lines = append(lines, "path "+p+" "+object)
}
sort.Strings(lines)
sum := sha256.Sum256([]byte("manifest " + manifest + "\n" + strings.Join(lines, "\n")))
return NamedTreePrefix + hex.EncodeToString(sum[:]), nil
}
+68
View File
@@ -0,0 +1,68 @@
package delivery
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
)
func sha256Of(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
// A module whose every artifact names its paths takes its tree from its manifest and those paths alone (novox/hq ADR
// 0499 clause 2): a change outside them leaves the value equal, a change to a named path or to the manifest changes it,
// and the order the paths are given in changes nothing.
func TestTheNamedTreeIsOfTheManifestAndTheNamedPathsAlone(t *testing.T) {
manifest := sha256Of(`{"module":"mesh-sdk"}`)
paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)}
v, err := NamedTree(manifest, paths)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(v, NamedTreePrefix) || !isHex64(strings.TrimPrefix(v, NamedTreePrefix)) {
t.Fatalf("the value %q is not %s and 64 hex digits", v, NamedTreePrefix)
}
// A change outside the named paths is no input at all: the same inputs give the same value, in any map order.
again, _ := NamedTree(manifest, map[string]string{"package.json": strings.Repeat("b", 40), "src": strings.Repeat("a", 40)})
if again != v {
t.Fatalf("the same inputs gave %q and %q", v, again)
}
changed, _ := NamedTree(manifest, map[string]string{"src": strings.Repeat("c", 40), "package.json": strings.Repeat("b", 40)})
if changed == v {
t.Fatal("a changed named path left the value equal")
}
otherManifest, _ := NamedTree(sha256Of(`{"module":"mesh-sdk","version":"2"}`), paths)
if otherManifest == v {
t.Fatal("a changed manifest left the value equal")
}
renamed, _ := NamedTree(manifest, map[string]string{"lib": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)})
if renamed == v {
t.Fatal("a path named differently with the same object left the value equal")
}
// The lines are fixed, so every computation of it agrees: pinned here.
want := "named:" + sha256Of("manifest "+manifest+"\npath package.json "+strings.Repeat("b", 40)+"\npath src "+strings.Repeat("a", 40))
if v != want {
t.Fatalf("NamedTree = %q, want %q", v, want)
}
}
func TestTheNamedTreeRefusesWhatItCannotPin(t *testing.T) {
manifest := sha256Of("{}")
for _, c := range []struct {
manifest string
paths map[string]string
says string
}{
{manifest, nil, "names no path"},
{manifest, map[string]string{"src": ""}, `the named path "src" has no git object`},
{"abc", map[string]string{"src": "x"}, "not a manifest digest"},
{manifest, map[string]string{"src/../x": "x"}, "is not a clean path"},
{manifest, map[string]string{"": "x"}, "is not a clean path"},
{manifest, map[string]string{"/src": "x"}, "is not a clean path"},
} {
if _, err := NamedTree(c.manifest, c.paths); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("NamedTree(%q, %v) = %v, wanted a refusal saying %q", c.manifest, c.paths, err, c.says)
}
}
}