Merge pull request 'Fetch a file the declaration names by digest from the bus's object bucket and verify it before writing, and run the integration tests in the merge check (issue 1695, ADR 0486 step 2)' (#126) from fix/1695-fetch-by-digest into main
This commit was merged in pull request #126.
This commit is contained in:
@@ -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 <file>` 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -608,6 +608,12 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, raw
|
|||||||
return errors.New(olderGenerationWords(*refused))
|
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.
|
// Said before anything is done.
|
||||||
steps := apply.Plan(d, known, origin)
|
steps := apply.Plan(d, known, origin)
|
||||||
if opts.json && opts.dryRun {
|
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)
|
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)
|
built, err := system.For(builtFor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return link.Report{Refused: err.Error()}
|
return link.Report{Refused: err.Error()}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module github.com/novox/mesh-host
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
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
|
github.com/nats-io/nats.go v1.54.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/sys v0.48.0
|
golang.org/x/sys v0.48.0
|
||||||
|
|||||||
@@ -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 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.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 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
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=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -1613,6 +1613,12 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
|
|||||||
return applyInto(r, previous)
|
return applyInto(r, previous)
|
||||||
}
|
}
|
||||||
out := begin(r)
|
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
|
// What actually goes on disk. For a sealed file the mesh never had this, and neither did
|
||||||
// whatever carried the declaration here.
|
// whatever carried the declaration here.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.<hash>.$O.B.C.<nuid>
|
||||||
|
// 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.<hash> 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)
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-2
@@ -214,7 +214,7 @@ func (q *Queue) step(ctx context.Context) bool {
|
|||||||
Superseded: declaredIn(latest.Body())}, setAside, old)
|
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 {
|
switch {
|
||||||
case report.OlderThan != nil:
|
case report.OlderThan != nil:
|
||||||
// **Refused, counted and said** (rule 2): one line here, the count on this and every later
|
// **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
|
return
|
||||||
}
|
}
|
||||||
report, ok := q.Reconcile(ctx)
|
report, ok := q.Reconcile(q.withFiles(ctx))
|
||||||
if !ok {
|
if !ok {
|
||||||
if asked {
|
if asked {
|
||||||
q.Say("asked to say what this node last applied, and the reconcile could not say it")
|
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()
|
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
|
// 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
|
// 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
|
// nothing more is said on it. A reconcile in hand is not waited for: a link that was dropped or roused
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Named capabilities. Constants rather than strings at the call site, because a capability
|
// 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.
|
// an unprivileged user, never by a version string or a sysctl alone.
|
||||||
CapUserNamespaces = "user-namespaces"
|
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
|
// 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
|
// 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
|
// 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.
|
// graphicalSession reports whether anything could display a window here.
|
||||||
//
|
//
|
||||||
// Environment rather than a probe, because a display server is reachable through a socket a
|
// 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{
|
return []Detector{
|
||||||
privileged{},
|
privileged{},
|
||||||
|
fileReferences{},
|
||||||
graphicalSession{},
|
graphicalSession{},
|
||||||
seat{},
|
seat{},
|
||||||
battery{},
|
battery{},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -52,8 +52,8 @@ if [ -z "${MESH_TEST_NATS:-}" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v gcc >/dev/null 2>&1; then
|
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
|
else
|
||||||
echo "NOT RACE-CHECKED: the toolchain holds no C compiler; the suite runs without the race detector"
|
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
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user