diff --git a/go/buildstep/trees.go b/go/buildstep/trees.go index 58b2ccd..372e46f 100644 --- a/go/buildstep/trees.go +++ b/go/buildstep/trees.go @@ -19,6 +19,9 @@ type ModuleTree struct { Tree string `json:"tree"` Manifest string `json:"manifest"` Locks map[string]string `json:"locks,omitempty"` + // Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree + // is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise. + Paths map[string]string `json:"paths,omitempty"` } var lockFiles = []string{"go.sum", "package-lock.json"} diff --git a/go/delivery/namedtree_layouts_test.go b/go/delivery/namedtree_layouts_test.go new file mode 100644 index 0000000..e06aedb --- /dev/null +++ b/go/delivery/namedtree_layouts_test.go @@ -0,0 +1,79 @@ +package delivery + +import ( + "strings" + "testing" +) + +// The named paths' git objects travel beside a module's other new-form inputs (novox/hq ADR 0499 clauses 2 and 3): in +// the forge bridge's messages (Forge.Paths), in the merge preview (PreviewModule.Paths) and in what the indexer keeps +// (KeptInputs2.Paths), so a computation from kept inputs gives the merge's value. Each is read as absent by an older +// reader, and an older value, without them, still decodes. +func TestTheNamedPathsObjectsRoundTripBesideTheOtherInputs(t *testing.T) { + manifest := sha256Of(`{"module":"mesh-sdk"}`) + paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)} + tree, err := NamedTree(manifest, paths) + if err != nil { + t.Fatal(err) + } + + m := Module{FP2: SourcePrefix2 + strings.Repeat("1", 64), Manifest: manifest, Repository: "novox/mesh-sdk", + Commit: strings.Repeat("c", 40), Inputs2: &KeptInputs2{Tree: tree, Paths: paths}} + m.FP = SourcePrefix + strings.Repeat("0", 64) + data, err := EncodeModule(m) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"paths":{`) { + t.Fatalf("the kept inputs carry no paths: %s", data) + } + back, err := DecodeModule(data) + if err != nil || back.Inputs2.Paths["src"] != paths["src"] || back.Inputs2.Tree != tree { + t.Fatalf("decoded %+v, %v", back.Inputs2, err) + } + // What the indexer keeps must compute to the tree it kept: a mismatch is a value no recompute could give. + bad := m + bad.Inputs2 = &KeptInputs2{Tree: tree, Paths: map[string]string{"src": strings.Repeat("d", 40)}} + if _, err := EncodeModule(bad); err == nil || !strings.Contains(err.Error(), "named tree") { + t.Fatalf("kept paths that do not compute to the kept tree were accepted: %v", err) + } + // An older value has no paths, and its directory tree stays its tree. + old := `{"v":1,"fp":"` + m.FP + `","manifest":"` + manifest + `","inputs2":{"tree":"` + strings.Repeat("e", 40) + + `"},"repository":"novox/x","commit":"` + strings.Repeat("c", 40) + `"}` + if o, err := DecodeModule([]byte(old)); err != nil || o.Inputs2.Paths != nil { + t.Fatalf("an older value: %+v, %v", o, err) + } + + f := Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 1, Head: strings.Repeat("h", 40), + Merge: strings.Repeat("m", 40), Root: strings.Repeat("r", 40), Trees: map[string]string{".": strings.Repeat("r", 40)}, + Paths: map[string]map[string]string{".": paths}} + data, err = EncodeForge(f) + if err != nil { + t.Fatal(err) + } + if fb, err := DecodeForge(data); err != nil || fb.Paths["."]["package.json"] != paths["package.json"] { + t.Fatalf("decoded forge %+v, %v", fb.Paths, err) + } + f.Paths = map[string]map[string]string{"other": paths} + if _, err := EncodeForge(f); err == nil { + t.Fatal("named paths of a directory the message has no tree of were accepted") + } + f.Paths = map[string]map[string]string{".": {"src": ""}} + if _, err := EncodeForge(f); err == nil { + t.Fatal("a named path with no object was accepted") + } + + p := Preview{Repository: "novox/mesh-sdk", Head: strings.Repeat("a", 40), Trunk: strings.Repeat("b", 40), + Merge: strings.Repeat("c", 40), Modules: map[string]PreviewModule{".": {Tree: tree, Manifest: manifest, Paths: paths}}} + data, err = EncodePreview(p) + if err != nil { + t.Fatal(err) + } + if pb, err := DecodePreview(data); err != nil || pb.Modules["."].Paths["src"] != paths["src"] { + t.Fatalf("decoded preview %+v, %v", pb.Modules, err) + } + p.Modules["."] = PreviewModule{Tree: strings.Repeat("e", 40), Manifest: manifest, Paths: paths} + if _, err := EncodePreview(p); err == nil || !strings.Contains(err.Error(), "named tree") { + t.Fatalf("a preview whose tree is not its paths' named tree was accepted: %v", err) + } +} diff --git a/go/delivery/preview.go b/go/delivery/preview.go index dd90cb7..3679146 100644 --- a/go/delivery/preview.go +++ b/go/delivery/preview.go @@ -98,12 +98,15 @@ func ParsePreviewKey(key string) (repository, head, trunk string, err error) { return repository, parts[1], parts[2], nil } -// PreviewModule is what the preview holds of one module directory: the tree sha of the directory, the digest of its +// PreviewModule is what the preview holds of one module directory: the tree sha of the directory (or its named tree), the digest of its // manifest (the key in the manifests bucket) and the hex sha256 of its lock files by path relative to it. type PreviewModule struct { Tree string `json:"tree"` Manifest string `json:"manifest"` Locks map[string]string `json:"locks,omitempty"` + // Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge; Tree + // is then their NamedTree (novox/hq ADR 0499 clause 2). Absent otherwise. + Paths map[string]string `json:"paths,omitempty"` } // Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge @@ -152,6 +155,9 @@ func (p Preview) check() error { return fmt.Errorf("delivery: the lock %q of the preview's module %q has an empty path or hash", path, dir) } } + if err := checkNamed(m.Tree, m.Manifest, m.Paths, fmt.Sprintf("the preview's module %q", dir)); err != nil { + return err + } } return nil } diff --git a/go/delivery/source.go b/go/delivery/source.go index 8d85b64..4d31073 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -411,6 +411,26 @@ type KeptInputs2 struct { Builder string `json:"builder,omitempty"` Contexts map[string]string `json:"contexts,omitempty"` Locks map[string]string `json:"locks,omitempty"` + // Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge, by + // path relative to the module's directory; Tree is then their NamedTree with the module's manifest digest, so a + // computation from kept inputs gives the merge's value (novox/hq ADR 0499 clause 2). Absent for every other module, + // whose Tree is its directory's tree, and in a value written before. + Paths map[string]string `json:"paths,omitempty"` +} + +// checkNamed refuses kept named paths that do not compute to the kept tree with the module's manifest digest. +func checkNamed(tree, manifest string, paths map[string]string, of string) error { + if len(paths) == 0 { + return nil + } + named, err := NamedTree(manifest, paths) + if err != nil { + return fmt.Errorf("delivery: %s: %w", of, err) + } + if named != tree { + return fmt.Errorf("delivery: %s keeps the tree %q, and its named paths give the named tree %q", of, tree, named) + } + return nil } // With is the full inputs of module m: the kept ones and the given toolchains. @@ -438,6 +458,11 @@ func (m Module) check() error { if m.Inputs2 != nil && m.Manifest == "" { return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest") } + if m.Inputs2 != nil { + if err := checkNamed(m.Inputs2.Tree, m.Manifest, m.Inputs2.Paths, "the module's kept inputs"); err != nil { + return err + } + } return checkNewForm(m.FP2, m.Manifest) } @@ -465,9 +490,13 @@ type Forge struct { // relative to it (novox/hq ADR 0465 clause 1). Older writers send neither. Manifests map[string]string `json:"manifests,omitempty"` Locks map[string]map[string]string `json:"locks,omitempty"` - Act string `json:"act,omitempty"` - Words string `json:"words,omitempty"` - Open []OpenPull `json:"open,omitempty"` + // Paths are, of each module directory whose manifest names paths on every artifact, each named path's git object + // id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The + // indexer names the paths; a message without them gives such a module none. Older writers send none. + Paths map[string]map[string]string `json:"paths,omitempty"` + Act string `json:"act,omitempty"` + Words string `json:"words,omitempty"` + Open []OpenPull `json:"open,omitempty"` } func (f Forge) check() error { @@ -537,8 +566,8 @@ func (f Forge) check() error { if len(f.Dirs) > 0 || f.DirsKnown { return fmt.Errorf("delivery: a refused message carries no module directories") } - if len(f.Manifests) > 0 || len(f.Locks) > 0 { - return fmt.Errorf("delivery: a refused message carries no manifests or lock hashes") + if len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 { + return fmt.Errorf("delivery: a refused message carries no manifests, lock hashes or named paths") } if f.Act == "" { return fmt.Errorf("delivery: a refused message needs the act it answers") @@ -571,6 +600,16 @@ func (f Forge) checkInputs(known func(dir string) bool, of string) error { } } } + for dir, paths := range f.Paths { + if !known(dir) { + return fmt.Errorf("delivery: the named paths of the directory %q are not of %s", dir, of) + } + for path, object := range paths { + if path == "" || object == "" { + return fmt.Errorf("delivery: the named path %q of the directory %q has an empty path or object", path, dir) + } + } + } return nil } @@ -578,7 +617,7 @@ func (f Forge) checkInputs(known func(dir string) bool, of string) error { // an open pull request that is not `/` and a number. func (f Forge) checkOpen() error { if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown || - f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || f.Root != "" || f.Act != "" || f.Words != "" { + f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" { return fmt.Errorf("delivery: an open message carries only the open pull requests it names") } for _, p := range f.Open {