diff --git a/go/catalogue/build.go b/go/catalogue/build.go index f4fcd25..91e3460 100644 --- a/go/catalogue/build.go +++ b/go/catalogue/build.go @@ -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. diff --git a/go/catalogue/paths_test.go b/go/catalogue/paths_test.go index e355383..33b9227 100644 --- a/go/catalogue/paths_test.go +++ b/go/catalogue/paths_test.go @@ -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) + } +} diff --git a/go/delivery/namedtree.go b/go/delivery/namedtree.go new file mode 100644 index 0000000..cc2cbad --- /dev/null +++ b/go/delivery/namedtree.go @@ -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 " and, sorted, +// "path ", 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 +} diff --git a/go/delivery/namedtree_test.go b/go/delivery/namedtree_test.go new file mode 100644 index 0000000..87769a1 --- /dev/null +++ b/go/delivery/namedtree_test.go @@ -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) + } + } +}