diff --git a/go/delivery/composed_value.go b/go/delivery/composed_value.go index e490425..a1afb5a 100644 --- a/go/delivery/composed_value.go +++ b/go/delivery/composed_value.go @@ -86,8 +86,10 @@ func (c Composed) check() error { if c.FP != "" { return fmt.Errorf("delivery: the node's own composition names no fingerprint, and this one names %s", c.FP) } - if c.Data || len(c.Filled) > 0 { - return fmt.Errorf("delivery: the node's own composition holds no module's data or filled files") + // It carries the filled paths of its own files, those of a module with no fingerprint of the new form moved + // into it (issue 1945), and never a module's data. + if c.Data { + return fmt.Errorf("delivery: the node's own composition holds no module's data") } } else { if _, err := ComposedKey(c.Node, c.Module, c.FP); err != nil { @@ -120,6 +122,20 @@ func (c Composed) check() error { files[f.Path] = append(files[f.Path], f) } } + // A file the provisioner fills says no content, so a composition that does not say it is filled would have the + // node-engine write it empty (issue 1945, live on 2026-10-25): refused, naming it. + filledHere := make(map[string]bool, len(c.Filled)) + for _, path := range c.Filled { + filledHere[path] = true + } + for path, fs := range files { + for _, f := range fs { + if f.OwnerAtRuntime == declaration.RuntimeOwned && !filledHere[path] && !saysContent(f) { + return fmt.Errorf("delivery: the file %q says no content and the composition of %q on %s does not say "+ + "the provisioner fills %s: the node-engine would write it empty", f.ID, c.Module, c.Node, path) + } + } + } for _, path := range c.Filled { if len(files[path]) == 0 { return fmt.Errorf("delivery: the composition of %q fills %q, and declares no file there", c.Module, path) @@ -138,6 +154,12 @@ func (c Composed) check() error { return nil } +// saysContent says a file names what to write: content, bytes, a digest or a sealed value. A file the provisioner fills +// names none of them. +func saysContent(f *declaration.File) bool { + return f.Content != "" || f.Bytes != "" || f.Digest != "" || f.Sealed != "" || f.VaultSealed != nil +} + // wire is c checked and made canonical, ready to marshal. func (c Composed) wire() (composedWire, error) { if err := c.check(); err != nil { diff --git a/go/delivery/composed_value_test.go b/go/delivery/composed_value_test.go index 960e4c5..f7085cd 100644 --- a/go/delivery/composed_value_test.go +++ b/go/delivery/composed_value_test.go @@ -320,3 +320,45 @@ func TestADottedModuleKeysEscaped(t *testing.T) { } } } + +// The node's own composition carries the filled paths of the files moved into it, the files of a module with no +// fingerprint of the new form: the provisioner fills them all the same (issue 1945, live on 2026-10-25: the paths were +// dropped and the node-engine wrote those files empty). It still holds no module's data. +func TestTheNodesOwnCompositionCarriesTheFilledPathsOfItsFiles(t *testing.T) { + filled := json.RawMessage(`{"id":"power.unit","type":"file","path":"/etc/systemd/system/p.service","mode":"0644","owner-at-runtime":"owned"}`) + own := Composed{Node: "ace", Resources: []json.RawMessage{filled}, Filled: []string{"/etc/systemd/system/p.service"}, + Own: NodeOwn{Provisioner: "mesh-provisioner-client"}} + raw, err := EncodeComposed(own) + if err != nil { + t.Fatalf("the node's own composition with a filled file: %v", err) + } + back, err := DecodeComposed(raw) + if err != nil || len(back.Filled) != 1 { + t.Fatalf("read back: %v %v", back.Filled, err) + } + if _, err := EncodeComposed(Composed{Node: "ace", Data: true, Resources: []json.RawMessage{filled}, + Filled: own.Filled}); err == nil { + t.Error("the node's own composition was let hold a module's data") + } +} + +// A file the provisioner fills says no content: in a composition that does not say it is filled, the node-engine would +// write it empty. Such a composition is refused, in the node's own as in a module's (issue 1945). +func TestAFileWithNoContentThatIsNotFilledIsRefused(t *testing.T) { + fp := fp2of('a') + for _, c := range []Composed{ + {Node: "ace", Resources: []json.RawMessage{json.RawMessage( + `{"id":"power.unit","type":"file","path":"/etc/p","mode":"0644","owner-at-runtime":"owned"}`)}}, + {Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{json.RawMessage( + `{"id":"m.f","type":"file","module":"m","path":"/etc/m","mode":"0644","owner-at-runtime":"owned"}`)}}, + } { + if _, err := EncodeComposed(c); err == nil || !strings.Contains(err.Error(), "empty") { + t.Errorf("%s/%s: %v, want a refusal saying it would be written empty", c.Node, c.Module, err) + } + } + // A file that says its content, even an empty one, is written as it says. + if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage( + `{"id":"x.f","type":"file","path":"/etc/x","mode":"0644","content":""}`)}}); err != nil { + t.Errorf("a file that says it is empty: %v", err) + } +}