Carry each named path's git object beside a module's other inputs (issue 1758, S3, ADR 0499 §2-§3)

Forge.Paths, PreviewModule.Paths, KeptInputs2.Paths and buildstep.ModuleTree.Paths,
each optional and read as absent by older readers; a kept or previewed tree that its
named paths do not compute to is refused, so a recompute gives the merge's value.
This commit is contained in:
2026-10-22 17:27:58 +02:00
parent 0c8d0f73fa
commit 8d65cb3016
4 changed files with 134 additions and 7 deletions
+3
View File
@@ -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"}
+79
View File
@@ -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)
}
}
+7 -1
View File
@@ -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
}
+45 -6
View File
@@ -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 `<owner>/<name>` 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 {