diff --git a/cmd/mesh-host/file_references_test.go b/cmd/mesh-host/file_references_test.go new file mode 100644 index 0000000..d7047f7 --- /dev/null +++ b/cmd/mesh-host/file_references_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/declaration" + "github.com/novox/mesh-host/internal/link" +) + +// aFilesLink is a link that serves objects by name. +type aFilesLink struct{ objects map[string][]byte } + +func (a aFilesLink) FileObject(_ context.Context, name string, _ int64) ([]byte, error) { + if b, ok := a.objects[name]; ok { + return b, nil + } + return nil, os.ErrNotExist +} + +func referenceDeclaration(path string, body []byte) []byte { + return []byte(`{"declaration":1,"resources":[{"id":"m.big","type":"file","path":"` + path + `","digest":"` + + declaration.DigestOf(body) + `","size":` + strconv.Itoa(len(body)) + `}]}`) +} + +// 1695/AC1 and AC2, through the whole apply: a reference is fetched, verified and written; a differing object +// refuses the declaration whole and writes nothing. +func TestADeclarationWithAFileByDigestAppliesFromTheObject(t *testing.T) { + unansweringPacman(t) + body := []byte(strings.Repeat("a line of a big file\n", 4000)) + name, _ := declaration.ObjectName(declaration.DigestOf(body)) + dir := t.TempDir() + target := filepath.Join(dir, "big") + opts := options{state: filepath.Join(dir, "state.json"), out: &strings.Builder{}} + raw := referenceDeclaration(target, body) + + bad := append([]byte(nil), body...) + bad[10] ^= 1 + ctx := link.WithFiles(context.Background(), aFilesLink{map[string][]byte{name: bad}}) + report := applyAndKeep(ctx, opts, raw, nil, nil, nil) + if report.Refused == "" || !strings.Contains(report.Refused, "m.big") { + t.Errorf("1695/AC2: a differing object did not refuse the declaration: %+v", report) + } + if _, err := os.Stat(target); err == nil { + t.Error("1695/AC2: a file was written from an object that differs") + } + + ctx = link.WithFiles(context.Background(), aFilesLink{map[string][]byte{name: body}}) + report = applyAndKeep(ctx, opts, raw, nil, nil, nil) + if report.Refused != "" { + t.Fatalf("1695/AC1: refused: %s", report.Refused) + } + got, err := os.ReadFile(target) + if err != nil || string(got) != string(body) { + t.Errorf("1695/AC1: the file holds %d bytes (%v), want %d", len(got), err, len(body)) + } +} + +// 1695/AC8: the hand-run `apply ` and `reconcile` have no bus: a reference whose file here verifies is applied +// as it is, any other refuses and writes nothing — never an empty file. +func TestTheHandApplyNeverWritesAReferenceEmpty(t *testing.T) { + unansweringPacman(t) + body := []byte(strings.Repeat("a line of a big file\n", 4000)) + dir := t.TempDir() + target := filepath.Join(dir, "big") + opts := options{state: filepath.Join(dir, "state.json"), out: &strings.Builder{}} + raw := referenceDeclaration(target, body) + d, err := declaration.ParseFileTrusted(raw) + if err != nil { + t.Fatal(err) + } + if err := runApply(context.Background(), opts, d, raw, fromFile); err == nil || !strings.Contains(err.Error(), "digest") { + t.Errorf("1695/AC8: a reference with no file and no bus was not refused: %v", err) + } + if _, err := os.Stat(target); err == nil { + t.Error("1695/AC8: a file was written for a reference that could not be had") + } + if err := os.WriteFile(target, body, 0o644); err != nil { + t.Fatal(err) + } + d, _ = declaration.ParseFileTrusted(raw) + if err := runApply(context.Background(), opts, d, raw, fromFile); err != nil && !strings.Contains(err.Error(), "applied only in part") { + t.Fatalf("1695/AC8: %v", err) + } + if got, _ := os.ReadFile(target); string(got) != string(body) { + t.Errorf("1695/AC8: the file holds %d bytes, want %d", len(got), len(body)) + } +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 4e0ff61..a0c4e1b 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -608,6 +608,12 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, raw return errors.New(olderGenerationWords(*refused)) } + // A file given by digest is had before the plan is drawn from it (novox/hq ADR 0486 §1, issue 1695). This command has + // no link to the bus, so a file already here that verifies is used, and any other is refused: never written empty. + if err := apply.ResolveFileReferences(ctx, d, link.FilesFrom(ctx)); err != nil { + return fmt.Errorf("a file of this declaration could not be had by its digest: %w", err) + } + // Said before anything is done. steps := apply.Plan(d, known, origin) if opts.json && opts.dryRun { @@ -2114,6 +2120,14 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto known.Mode = apply.ModeOf(declared) } + // **A file given by digest is fetched and verified before anything of the declaration is applied** (novox/hq ADR + // 0486 §1, issue 1695): one object missing or differing refuses the declaration whole. The signed bytes are what is + // kept; only what is applied has the content in place of the reference. + if err := apply.ResolveFileReferences(ctx, declared, link.FilesFrom(ctx)); err != nil { + return link.Report{Declared: digestOf(raw), Order: order, + Refused: "a file of this declaration could not be had by its digest: " + err.Error()} + } + built, err := system.For(builtFor) if err != nil { return link.Report{Refused: err.Error()} diff --git a/go.mod b/go.mod index e8898e6..f3cfcb0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021124357-863a981e014e github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 3a08c17..b97a407 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26 h1:oT1l3ZdjyF82MK14QMLKzoH2+iOFH/+UEYdlK3T2f7k= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f h1:7J/9jOfSfjfYpf8MOj96HwQRViQUkBNMb71RHP2mjn8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021124357-863a981e014e h1:eq29ENikw3XfEXjxL5OdnTbavv5pu1/UnQhWWuf5V5I= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021124357-863a981e014e/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= diff --git a/internal/apply/apply.go b/internal/apply/apply.go index f723022..f55f20e 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -1613,6 +1613,12 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF return applyInto(r, previous) } out := begin(r) + // A file given by its digest is content to be had, not content: written as it stands it would be an empty file + // (novox/hq ADR 0486 §1, issue 1695). ResolveFileReferences has put its content in before any apply. + if r.IsReference() { + return out, fmt.Errorf("%s is given by digest %s and was not resolved to its content before the apply; "+ + "it is not written empty", r.Path, r.Digest) + } // What actually goes on disk. For a sealed file the mesh never had this, and neither did // whatever carried the declaration here. diff --git a/internal/apply/references.go b/internal/apply/references.go new file mode 100644 index 0000000..b5b5ca2 --- /dev/null +++ b/internal/apply/references.go @@ -0,0 +1,95 @@ +package apply + +import ( + "context" + "fmt" + "os" + "path/filepath" + "unicode/utf8" + + "encoding/base64" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// FetchObject reads the object of the declaration-files bucket named by name, no larger than limit bytes (novox/hq ADR +// 0486 §1, §2). Nil is a node-engine with no link to the bus: a file by reference is then only the file already there. +type FetchObject func(ctx context.Context, name string, limit int64) ([]byte, error) + +// ResolveFileReferences puts the content of every file given by digest and size into the declaration, so what is +// applied afterwards is the declaration as it was before references existed (novox/hq ADR 0486 §1, issue 1695). +// +// **Nothing of the declaration is applied before every reference is resolved**: a missing object, one whose size or +// sha256 differs from what the signed declaration names, or a fetch that fails, refuses the declaration whole. The +// content is checked with File.Verify before it is kept, and is only ever written by the apply that follows. +// +// A file already on the machine whose content verifies is used as it is and fetches nothing: the reconcile of an +// unchanged declaration, and a node that is asleep and has no bus, apply as they always did. One object is fetched +// once however many files name its digest. +func ResolveFileReferences(ctx context.Context, d *declaration.Declaration, fetch FetchObject) error { + fetched := map[string][]byte{} + for i, r := range d.Resources { + f, ok := r.(*declaration.File) + if !ok || !f.IsReference() { + continue + } + body, err := referencedContent(ctx, f, fetch, fetched) + if err != nil { + return err + } + inline := *f + inline.Digest, inline.Size = "", 0 + if utf8.Valid(body) { + inline.Content = string(body) + } else { + inline.Bytes = base64.StdEncoding.EncodeToString(body) + } + d.Resources[i] = &inline + } + return nil +} + +func referencedContent(ctx context.Context, f *declaration.File, fetch FetchObject, + fetched map[string][]byte) ([]byte, error) { + if body, ok := fetched[f.Digest]; ok { + return body, nil + } + if body, ok := alreadyThere(f); ok { + fetched[f.Digest] = body + return body, nil + } + name, err := declaration.ObjectName(f.Digest) + if err != nil { + return nil, fmt.Errorf("file %s (%s): %w", f.ID, f.Path, err) + } + if fetch == nil { + return nil, fmt.Errorf("file %s (%s) is given by digest %s and is not on this machine, and this node-engine "+ + "has no link to the bus to fetch it from", f.ID, f.Path, f.Digest) + } + body, err := fetch(ctx, name, f.Size) + if err != nil { + return nil, fmt.Errorf("file %s (%s): fetching the object %s of the bucket %s: %w", + f.ID, f.Path, name, declaration.FileBucket, err) + } + if err := f.Verify(body); err != nil { + return nil, fmt.Errorf("%s (%s): %w; nothing was applied", f.ID, f.Path, err) + } + fetched[f.Digest] = body + return body, nil +} + +// alreadyThere is the content of the file on the machine when it is what the reference names. +func alreadyThere(f *declaration.File) ([]byte, bool) { + if !filepath.IsAbs(f.Path) { + return nil, false + } + info, err := os.Lstat(f.Path) + if err != nil || !info.Mode().IsRegular() || info.Size() != f.Size { + return nil, false + } + body, err := os.ReadFile(f.Path) + if err != nil || f.Verify(body) != nil { + return nil, false + } + return body, true +} diff --git a/internal/apply/references_test.go b/internal/apply/references_test.go new file mode 100644 index 0000000..e02ffac --- /dev/null +++ b/internal/apply/references_test.go @@ -0,0 +1,132 @@ +package apply + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/declaration" + "github.com/novox/mesh-host/internal/store" +) + +func referenceTo(t *testing.T, path string, body []byte) *declaration.File { + t.Helper() + return &declaration.File{ID: "m.big", Type: declaration.TypeFile, Path: path, + Digest: declaration.DigestOf(body), Size: int64(len(body))} +} + +func objects(m map[string][]byte, asked *[]string) FetchObject { + return func(_ context.Context, name string, limit int64) ([]byte, error) { + *asked = append(*asked, name) + b, ok := m[name] + if !ok { + return nil, errors.New("object not found") + } + return b, nil + } +} + +func objectOf(body []byte) string { + name, _ := declaration.ObjectName(declaration.DigestOf(body)) + return name +} + +// 1695/AC1: both forms apply: a file by reference becomes the inline file, an inline one is left as it is. +func TestAFileByReferenceBecomesTheInlineFile(t *testing.T) { + body := []byte(strings.Repeat("a line\n", 20000)) + inline := &declaration.File{ID: "m.small", Type: declaration.TypeFile, Path: "/etc/small", Content: "x"} + d := &declaration.Declaration{Resources: []declaration.Resource{inline, referenceTo(t, "/nowhere/big", body)}} + var asked []string + if err := ResolveFileReferences(context.Background(), d, objects(map[string][]byte{objectOf(body): body}, &asked)); err != nil { + t.Fatal(err) + } + got := d.Resources[1].(*declaration.File) + if got.IsReference() || got.Content != string(body) || got.Path != "/nowhere/big" || d.Resources[0] != declaration.Resource(inline) { + t.Errorf("1695/AC1: resolved to %+v", got) + } + if len(asked) != 1 || asked[0] != objectOf(body) { + t.Errorf("1695/AC1: asked %v", asked) + } +} + +// 1695/AC2: an object that differs by one byte, or in size, fails the whole resolve and gives no content. +func TestAnObjectThatDiffersFailsTheApply(t *testing.T) { + body := []byte(strings.Repeat("x", 70000)) + bad := append([]byte(nil), body...) + bad[3] ^= 1 + for name, served := range map[string][]byte{"one byte": bad, "shorter": body[1:]} { + d := &declaration.Declaration{Resources: []declaration.Resource{referenceTo(t, "/nowhere/big", body)}} + var asked []string + err := ResolveFileReferences(context.Background(), d, objects(map[string][]byte{objectOf(body): served}, &asked)) + if err == nil || !strings.Contains(err.Error(), "nothing was applied") { + t.Errorf("1695/AC2: %s: %v", name, err) + } + if !d.Resources[0].(*declaration.File).IsReference() { + t.Errorf("1695/AC2: %s: the reference was replaced by unverified content", name) + } + } +} + +// 1695/AC3: a missing object, or a node with no bus, fails the apply naming the file. +func TestAMissingObjectFailsTheApply(t *testing.T) { + body := []byte(strings.Repeat("y", 70000)) + d := &declaration.Declaration{Resources: []declaration.Resource{referenceTo(t, "/nowhere/big", body)}} + var asked []string + if err := ResolveFileReferences(context.Background(), d, objects(nil, &asked)); err == nil || + !strings.Contains(err.Error(), "m.big") || !strings.Contains(err.Error(), "declaration-files") { + t.Errorf("1695/AC3: missing object: %v", err) + } + if err := ResolveFileReferences(context.Background(), d, nil); err == nil || !strings.Contains(err.Error(), "no link to the bus") { + t.Errorf("1695/AC3: no bus: %v", err) + } +} + +// 1695/AC4: a file already on the machine that verifies is used and nothing is fetched; one that does not is fetched. +func TestAVerifiedFileOnTheMachineFetchesNothing(t *testing.T) { + body := []byte(strings.Repeat("z", 70000)) + path := filepath.Join(t.TempDir(), "big") + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatal(err) + } + var asked []string + d := &declaration.Declaration{Resources: []declaration.Resource{referenceTo(t, path, body)}} + if err := ResolveFileReferences(context.Background(), d, objects(nil, &asked)); err != nil || len(asked) != 0 { + t.Errorf("1695/AC4: err %v, asked %v", err, asked) + } + if err := os.WriteFile(path, body[:100], 0o644); err != nil { + t.Fatal(err) + } + d = &declaration.Declaration{Resources: []declaration.Resource{referenceTo(t, path, body)}} + if err := ResolveFileReferences(context.Background(), d, objects(map[string][]byte{objectOf(body): body}, &asked)); err != nil || len(asked) != 1 { + t.Errorf("1695/AC4: a differing file: err %v, asked %v", err, asked) + } +} + +// 1695/AC5: binary content comes back as bytes, and two files naming one digest fetch it once. +func TestBinaryContentAndOneFetchPerDigest(t *testing.T) { + body := append([]byte{0xff, 0xfe, 0x00}, []byte(strings.Repeat("b", 70000))...) + d := &declaration.Declaration{Resources: []declaration.Resource{referenceTo(t, "/n/a", body), referenceTo(t, "/n/b", body)}} + var asked []string + if err := ResolveFileReferences(context.Background(), d, objects(map[string][]byte{objectOf(body): body}, &asked)); err != nil { + t.Fatal(err) + } + a, _ := d.Resources[0].(*declaration.File).Body() + if d.Resources[0].(*declaration.File).Bytes == "" || string(a) != string(body) || len(asked) != 1 { + t.Errorf("1695/AC5: bytes %q asked %v", d.Resources[0].(*declaration.File).Bytes[:4], asked) + } +} + +// 1695/AC9: an unresolved reference reaching the apply is refused, never written as an empty file. +func TestAnUnresolvedReferenceIsNeverWrittenEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "big") + f := referenceTo(t, path, []byte(strings.Repeat("q", 70000))) + if _, err := applyFile(f, store.Applied{}, nil, nil); err == nil || !strings.Contains(err.Error(), "not written empty") { + t.Errorf("1695/AC9: %v", err) + } + if _, err := os.Stat(path); err == nil { + t.Error("1695/AC9: a file was written") + } +} diff --git a/internal/link/files.go b/internal/link/files.go new file mode 100644 index 0000000..8ba885a --- /dev/null +++ b/internal/link/files.go @@ -0,0 +1,141 @@ +package link + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" + + "git.novox.be/novox/mesh-sdk/go/declaration" + "github.com/nats-io/nats.go" +) + +// FilesBus is a link that can read an object of the bucket the controller keeps a declaration's large files in +// (novox/hq ADR 0486 §1, §2). Separate from Bus, as HaveBus is: a link that cannot is still a link, and a declaration +// whose files are given by digest is then refused whole, naming that. +type FilesBus interface { + // FileObject is the object of that name, no larger than limit bytes, read within ctx. **Reads by name only**: a + // node-engine's account may neither list the bucket nor write it (ADR 0486 §2). + FileObject(ctx context.Context, name string, limit int64) ([]byte, error) +} + +type filesKey struct{} + +// WithFiles is ctx carrying the link a declaration's file references are fetched on. +func WithFiles(ctx context.Context, bus any) context.Context { + if f, ok := bus.(FilesBus); ok { + return context.WithValue(ctx, filesKey{}, f) + } + return ctx +} + +// FilesFrom is the fetch the apply's context carries, or nil when the node has no such link. +func FilesFrom(ctx context.Context) func(context.Context, string, int64) ([]byte, error) { + if f, ok := ctx.Value(filesKey{}).(FilesBus); ok { + return f.FileObject + } + return nil +} + +// objectInfo is the part of an object store's metadata record this read needs (nats.go's ObjectInfo, as the store writes it). +type objectInfo struct { + Name string `json:"name"` + NUID string `json:"nuid"` + Size uint64 `json:"size"` + Digest string `json:"digest"` + Deleted bool `json:"deleted"` +} + +// FileObject reads one object by name from the declaration-files bucket, without listing the bucket or looking at +// anything but the object it names (novox/hq ADR 0486 §2: a node-engine's account may read by name and may neither +// list nor write). +// +// **The bus subjects this uses, and no others** (the grant of issue 1697 is exactly these; reviewer's note 5 there), for +// bucket B = declaration-files, stream S = OBJ_B, and E the base64 (URL alphabet) of the object's name: +// +// request $JS.API.DIRECT.GET.S.$O.B.M.E the object's metadata. Grant only as ...DIRECT.GET.S.$O.B.M.*, never +// the bare ...DIRECT.GET.S, which takes a sequence number +// request $JS.API.CONSUMER.CREATE.S..$O.B.C. +// an ordered consumer on the object's chunks. Grant only as +// ...CREATE.S.*.$O.B.C.*, never with ">" (a consumer filtered on +// $O.B.M.> would read every object's name). Repeated on a reset +// subscribe a new inbox under the connection's own inbox prefix, where the chunks are delivered +// publish $JS.FC.S.> the flow-control replies of the ordered consumer; without them a +// large object stalls at the flow-control window +// request $JS.API.CONSUMER.DELETE.S. on unsubscribe; best effort, the consumer's inactivity threshold +// removes it if refused +// +// It calls no STREAM.INFO and no STREAM.MSG.GET: js.ObjectStore asks for the stream's info (which lists every object's name) +// and store.Get reads the metadata with a message get a grant cannot keep from reading by sequence. +func (b OverNATS) FileObject(ctx context.Context, name string, limit int64) ([]byte, error) { + js := b.JS + wait := 10 * time.Second + if at, ok := ctx.Deadline(); ok { + if wait = time.Until(at); wait <= 0 { + return nil, context.DeadlineExceeded + } + } + if b.Conn != nil { + bounded, err := b.Conn.JetStream(nats.MaxWait(wait)) + if err != nil { + return nil, err + } + js = bounded + } + stream := "OBJ_" + declaration.FileBucket + meta, err := js.GetLastMsg(stream, "$O."+declaration.FileBucket+".M."+base64.URLEncoding.EncodeToString([]byte(name)), + nats.DirectGet(), nats.Context(ctx)) + if err != nil { + if errors.Is(err, nats.ErrMsgNotFound) { + return nil, fmt.Errorf("no object %s in the bucket %s", name, declaration.FileBucket) + } + return nil, fmt.Errorf("the metadata of object %s: %w", name, err) + } + var info objectInfo + if err := json.Unmarshal(meta.Data, &info); err != nil { + return nil, fmt.Errorf("the metadata of object %s is not readable: %w", name, err) + } + if info.Deleted { + return nil, fmt.Errorf("the object %s was deleted from the bucket %s", name, declaration.FileBucket) + } + if info.Size > uint64(limit) { + return nil, fmt.Errorf("the object is larger than the %d bytes the declaration names", limit) + } + if info.Size == 0 { + return []byte{}, nil + } + sub, err := js.SubscribeSync("$O."+declaration.FileBucket+".C."+info.NUID, + nats.OrderedConsumer(), nats.BindStream(stream)) + if err != nil { + return nil, fmt.Errorf("reading the chunks of object %s: %w", name, err) + } + defer sub.Unsubscribe() + body := make([]byte, 0, info.Size) + for uint64(len(body)) < info.Size { + msg, err := sub.NextMsgWithContext(ctx) + if err != nil { + return nil, fmt.Errorf("reading the chunks of object %s (%d of %d bytes): %w", name, len(body), info.Size, err) + } + if uint64(len(body))+uint64(len(msg.Data)) > info.Size { + return nil, fmt.Errorf("the chunks of object %s are more than the %d bytes its metadata says", name, info.Size) + } + body = append(body, msg.Data...) + } + sum := sha256.Sum256(body) + if want := "SHA-256=" + base64.URLEncoding.EncodeToString(sum[:]); info.Digest != "" && info.Digest != want { + return nil, fmt.Errorf("the chunks of object %s do not hash to the digest of its metadata", name) + } + return body, nil +} + +func (l *natsLink) FileObject(ctx context.Context, name string, limit int64) ([]byte, error) { + return OverNATS{Conn: l.conn, JS: l.js}.FileObject(ctx, name, limit) +} + +var ( + _ FilesBus = OverNATS{} + _ FilesBus = (*natsLink)(nil) +) diff --git a/internal/link/files_nats_test.go b/internal/link/files_nats_test.go new file mode 100644 index 0000000..dcaa09b --- /dev/null +++ b/internal/link/files_nats_test.go @@ -0,0 +1,94 @@ +package link + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/declaration" + "github.com/nats-io/nats.go" +) + +// 1695/AC7: (untagged on purpose: the merge check of the trunk runs the tagless suite against its throwaway bus, and the +// test skips without MESH_TEST_NATS) against a real bus with the bucket declaration-files, over the bus's default maximum message size, a +// node-engine's link reads three objects that together are over 3 MiB by name, verifies each with File.Verify, and +// refuses an object that is absent, one larger than the declaration says, and one that differs by a byte. +// +// MESH_TEST_NATS=nats://127.0.0.1:4222 go test ./internal/link/ -run TestFilesAreFetchedByDigestFromTheBucket +// +// The bucket is made here: in the mesh the controller makes and fills it, and the node-engine only reads. +func TestFilesAreFetchedByDigestFromTheBucket(t *testing.T) { + conn, js := aBus(t) + store, err := js.ObjectStore(declaration.FileBucket) + if err != nil { + if store, err = js.CreateObjectStore(&nats.ObjectStoreConfig{Bucket: declaration.FileBucket}); err != nil { + t.Fatal(err) + } + } + var files []*declaration.File + bodies := map[string][]byte{} + total := 0 + for i, b := range []byte{'a', 'b', 'c'} { + body := append(bytes.Repeat([]byte{b}, 1100*1024), byte(i)) + total += len(body) + f := &declaration.File{ID: "m.f" + string(b), Type: declaration.TypeFile, Path: "/x/" + string(b), + Digest: declaration.DigestOf(body), Size: int64(len(body))} + name, err := declaration.ObjectName(f.Digest) + if err != nil { + t.Fatal(err) + } + if _, err := store.PutBytes(name, body); err != nil { + t.Fatal(err) + } + files, bodies[f.ID] = append(files, f), body + } + if total <= 3<<20 { + t.Fatalf("the objects add up to %d bytes, not over 3 MiB", total) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + bus := OverNATS{Conn: conn, JS: js} + fetch := FilesFrom(WithFiles(ctx, bus)) + if fetch == nil { + t.Fatal("a link over the bus carries no fetch") + } + for _, f := range files { + name, _ := declaration.ObjectName(f.Digest) + got, err := fetch(ctx, name, f.Size) + if err != nil { + t.Fatalf("fetching %s: %v", f.ID, err) + } + if err := f.Verify(got); err != nil { + t.Errorf("%s fetched and does not verify: %v", f.ID, err) + } + if !bytes.Equal(got, bodies[f.ID]) { + t.Errorf("%s fetched with other content", f.ID) + } + } + + name, _ := declaration.ObjectName(files[0].Digest) + if _, err := fetch(ctx, name, files[0].Size-1); err == nil || !strings.Contains(err.Error(), "larger") { + t.Errorf("an object larger than the declaration says was read: %v", err) + } + absent := strings.Repeat("0", 64) + if _, err := fetch(ctx, absent, 10); err == nil { + t.Error("an absent object was read") + } + // A changed byte is stored under the digest of the original: what the controller never does and an attacker might. + bad := append([]byte(nil), bodies["m.fa"]...) + bad[5] ^= 1 + swapped := strings.Repeat("1", 64) + if _, err := store.PutBytes(swapped, bad); err != nil { + t.Fatal(err) + } + got, err := fetch(ctx, swapped, files[0].Size) + if err != nil { + t.Fatal(err) + } + if err := files[0].Verify(got); err == nil { + t.Error("an object that differs by a byte verified") + } +} diff --git a/internal/link/queue.go b/internal/link/queue.go index 1168aa8..ddc2ea5 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -214,7 +214,7 @@ func (q *Queue) step(ctx context.Context) bool { Superseded: declaredIn(latest.Body())}, setAside, old) } - report := handleBody(ctx, q.Membership, latest.Body(), q.Apply) + report := handleBody(q.withFiles(ctx), q.Membership, latest.Body(), q.Apply) switch { case report.OlderThan != nil: // **Refused, counted and said** (rule 2): one line here, the count on this and every later @@ -260,7 +260,7 @@ func (q *Queue) reconcile(ctx context.Context, asked bool) { } return } - report, ok := q.Reconcile(ctx) + report, ok := q.Reconcile(q.withFiles(ctx)) if !ok { if asked { q.Say("asked to say what this node last applied, and the reconcile could not say it") @@ -457,6 +457,18 @@ func (q *Queue) attach(ctx context.Context, bus Bus) { q.mu.Unlock() } +// withFiles is ctx carrying the link now open, so an apply can fetch a declaration's files given by digest (novox/hq ADR +// 0486, issue 1695). A queue with no link open carries none. +func (q *Queue) withFiles(ctx context.Context) context.Context { + q.mu.Lock() + bus := q.bus + q.mu.Unlock() + if bus == nil { + return ctx + } + return WithFiles(ctx, bus) +} + // detach is the link closing: it waits for a delivery in hand to be applied and said — an apply that // stood aside for its successor is reported on this link before it is let go (issue 264) — and then // nothing more is said on it. A reconcile in hand is not waited for: a link that was dropped or roused diff --git a/internal/profile/detectors.go b/internal/profile/detectors.go index 8e245d0..7f400bd 100644 --- a/internal/profile/detectors.go +++ b/internal/profile/detectors.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "strings" + + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Named capabilities. Constants rather than strings at the call site, because a capability @@ -29,6 +31,11 @@ const ( // an unprivileged user, never by a version string or a sysctl alone. CapUserNamespaces = "user-namespaces" + // CapFileReferences says this node-engine reads a declaration's file given as a digest and size, fetches it from the + // bus's object bucket and checks it before writing (novox/hq ADR 0486 §5, issue 1695). A fact about the program, as + // CapContainerGroups: the controller sends references only to a node that reports it. + CapFileReferences = declaration.FileReferencesCapability + // The network manager this machine runs, one capability per dialect (novox/hq ADR 0161): the // uplink seat's holder declares its own, so the holder for a manager the machine does not run // is refused the way any missing capability is, naming it. Active, not installed — a machine @@ -152,6 +159,19 @@ func (privileged) Detect(context.Context) Verdict { } } +// fileReferences reports that this node-engine reads a file given by digest. True of the program that is running. +type fileReferences struct{} + +func (fileReferences) Name() string { return CapFileReferences } + +func (fileReferences) Detect(context.Context) Verdict { + return Verdict{ + Name: CapFileReferences, Present: true, + Detail: "fetches a file given by digest from the bus's object bucket and verifies it before writing", + How: "a fact about this program, not probed: the node-engine that answers reads the field", + } +} + // graphicalSession reports whether anything could display a window here. // // Environment rather than a probe, because a display server is reachable through a socket a @@ -190,6 +210,7 @@ func Default(runner Runner) []Detector { } return []Detector{ privileged{}, + fileReferences{}, graphicalSession{}, seat{}, battery{}, diff --git a/internal/profile/file_references_test.go b/internal/profile/file_references_test.go new file mode 100644 index 0000000..1ea04d9 --- /dev/null +++ b/internal/profile/file_references_test.go @@ -0,0 +1,18 @@ +package profile + +import ( + "context" + "testing" + "time" +) + +// 1695/AC6: the node's report carries the capability file-references. +func TestTheProfileSaysTheNodeEngineReadsFileReferences(t *testing.T) { + got := Detect(context.Background(), Default(func(context.Context, string, ...string) (string, error) { + return "", nil + }), time.Second) + v := verdictOf(t, got, CapFileReferences) + if !v.Present || v.Detail == "" || v.How == "" || CapFileReferences != "file-references" { + t.Errorf("1695/AC6: file-references = %+v", v) + } +} diff --git a/merge-check.sh b/merge-check.sh index dabdee8..14c2060 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -52,8 +52,8 @@ if [ -z "${MESH_TEST_NATS:-}" ]; then fi if command -v gcc >/dev/null 2>&1; then - CGO_ENABLED=1 go test -race -count=1 ./... + CGO_ENABLED=1 go test -tags integration -race -count=1 ./... else echo "NOT RACE-CHECKED: the toolchain holds no C compiler; the suite runs without the race detector" - CGO_ENABLED=0 go test -count=1 ./... + CGO_ENABLED=0 go test -tags integration -count=1 ./... fi