package apply import ( "archive/tar" "bytes" "compress/gzip" "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" ) // The shapes added so that most of what a person installs is expressible. // // A shell, a chat client, a desktop are a package plus configuration in somebody's home, and a // mesh with no user can manage /etc and nothing anybody looks at. func declare(t *testing.T, resources string) *declaration.Declaration { t.Helper() d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[` + resources + `]}`)) if err != nil { t.Fatal(err) } return d } func TestAFileMayBeBytesRatherThanText(t *testing.T) { // A wallpaper, a font, an icon. Stored as its own encoding it would be a wallpaper nothing // can open. dir := t.TempDir() original := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff} d := declare(t, `{"id":"w","type":"file","path":"`+dir+`/wall.png","bytes":"`+ base64.StdEncoding.EncodeToString(original)+`"}`) if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil); err != nil { t.Fatal(err) } on, err := os.ReadFile(dir + "/wall.png") if err != nil { t.Fatal(err) } if !bytes.Equal(on, original) { t.Fatalf("the bytes did not survive: %x", on) } } func TestAFileSaysWhatIsInItExactlyOnce(t *testing.T) { // Three ways of saying it and no precedence between them, so "what is in this file" is // answerable by looking rather than by knowing which field wins. _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[ {"id":"f","type":"file","path":"/etc/x","content":"a","bytes":"YQ=="}]}`)) if err == nil { t.Fatal("a file that was both text and bytes was accepted") } if !strings.Contains(err.Error(), "exactly once") { t.Fatalf("unhelpful refusal: %v", err) } } func TestBytesThatAreNotBase64AreRefused(t *testing.T) { dir := t.TempDir() d := declare(t, `{"id":"w","type":"file","path":"`+dir+`/x","bytes":"not base64!!"}`) _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil) if err == nil { t.Fatal("a file carrying nonsense was written") } if _, statErr := os.Stat(dir + "/x"); statErr == nil { t.Fatal("something was written before the failure") } } // A gzipped tar, and its digest, built here so the test does not depend on a fixture nobody can // regenerate. func anArchive(t *testing.T, files map[string]string) ([]byte, string) { t.Helper() var raw bytes.Buffer zipped := gzip.NewWriter(&raw) writer := tar.NewWriter(zipped) for name, body := range files { if err := writer.WriteHeader(&tar.Header{ Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg, }); err != nil { t.Fatal(err) } if _, err := writer.Write([]byte(body)); err != nil { t.Fatal(err) } } if err := writer.Close(); err != nil { t.Fatal(err) } if err := zipped.Close(); err != nil { t.Fatal(err) } sum := sha256.Sum256(raw.Bytes()) return raw.Bytes(), "sha256:" + hex.EncodeToString(sum[:]) } func serving(t *testing.T, body []byte) string { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(body) })) t.Cleanup(server.Close) return server.URL + "/theme.tar.gz" } func TestAnArchiveIsUnpacked(t *testing.T) { body, digest := anArchive(t, map[string]string{ "config/theme.conf": "dark", "config/icons/one.svg": "", }) dir := t.TempDir() d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+ `","digest":"`+digest+`","path":"`+dir+`/theme"}`) report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil) if err != nil { t.Fatal(err) } if !report.Changed() { t.Fatal("nothing changed") } on, err := os.ReadFile(dir + "/theme/config/theme.conf") if err != nil { t.Fatal(err) } if string(on) != "dark" { t.Fatalf("got %q", on) } } func TestAnArchiveThatIsNotWhatWasDeclaredIsRefusedBeforeAnythingIsWritten(t *testing.T) { // The only thing making bytes from a network the mesh does not control safe to unpack is // that they hash to what was declared. body, _ := anArchive(t, map[string]string{"a": "b"}) dir := t.TempDir() d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+ `","digest":"sha256:`+strings.Repeat("ab", 32)+`","path":"`+dir+`/theme"}`) _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil) if err == nil { t.Fatal("an archive that was not what was declared was unpacked") } if entries, _ := os.ReadDir(dir); len(entries) != 0 { t.Fatal("something was written before the digest was checked") } } func TestAnArchiveCannotWriteOutsideWhereItWasUnpacked(t *testing.T) { // The oldest bug in unpacking. Checked against the resolved root rather than by looking for // "..", because there is more than one way to name a path that escapes. body, digest := anArchive(t, map[string]string{"../../escaped": "no"}) dir := t.TempDir() d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+ `","digest":"`+digest+`","path":"`+dir+`/theme"}`) _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil) if err != nil && !strings.Contains(err.Error(), "outside") { t.Fatalf("refused for the wrong reason: %v", err) } if _, statErr := os.Stat(dir + "/escaped"); statErr == nil { t.Fatal("a file landed outside the directory it was unpacked into") } if err == nil { t.Fatal("an escaping entry was accepted") } } func TestAnUnpackedArchiveIsNotFetchedAgainForNothing(t *testing.T) { // The digest is the whole identity of an archive, so a matching record means the tree came // from these exact bytes. Applying twice must not report work. body, digest := anArchive(t, map[string]string{"a": "b"}) dir := t.TempDir() d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+ `","digest":"`+digest+`","path":"`+dir+`/theme"}`) _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil) if err != nil { t.Fatal(err) } again, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil) if err != nil { t.Fatal(err) } if again.Changed() { said, _ := json.Marshal(again) t.Fatalf("the second apply did work: %s", said) } } // Defends novox/hq ADR 0012: the mesh creates no symlinks — a derived file is a copy. // // The archive is the one path where a symlink could arrive without anybody declaring it, which is // why the refusal lives here. ADR 0012 was earned by production data loss through a symlink // resolved inside a container volume path. func TestAnArchiveWithSomethingThatIsNotAFileIsRefused(t *testing.T) { // A theme needing a symlink would otherwise arrive silently incomplete, and a device node in // an archive is not something to unpack quietly onto a machine. var raw bytes.Buffer zipped := gzip.NewWriter(&raw) writer := tar.NewWriter(zipped) if err := writer.WriteHeader(&tar.Header{ Name: "link", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd", Mode: 0o777, }); err != nil { t.Fatal(err) } writer.Close() zipped.Close() sum := sha256.Sum256(raw.Bytes()) digest := "sha256:" + hex.EncodeToString(sum[:]) dir := t.TempDir() d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, raw.Bytes())+ `","digest":"`+digest+`","path":"`+dir+`/theme"}`) _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil) if err == nil { t.Fatal("a symlink was unpacked") } if !strings.Contains(err.Error(), "files and directories") { t.Fatalf("refused for the wrong reason: %v", err) } } func TestAnArchiveMustBePinned(t *testing.T) { _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[ {"id":"t","type":"archive","source":"https://example.invalid/a.tgz","path":"/opt/t"}]}`)) if err == nil { t.Fatal("an unpinned archive was accepted") } if !strings.Contains(err.Error(), "digest") { t.Fatalf("unhelpful refusal: %v", err) } } // Defends novox/hq ADR 0029: a network is a shape so that it can be removed. // // The whole argument for widening the vocabulary is lifecycle — an action could create one and // nothing could ever take it away — so removal is the assertion that matters, not creation. func TestANetworkIsCreatedAndThenRemovedWhenNoLongerDeclared(t *testing.T) { var calls []string there := map[string]bool{} run := func(_ context.Context, name string, args ...string) (string, error) { calls = append(calls, name+" "+strings.Join(args, " ")) if name != "docker" || len(args) < 2 || args[0] != "network" { return "", nil // the runtime probe } switch args[1] { case "inspect": if !there[args[2]] { return "", fmt.Errorf("no such network") } case "create": there[args[2]] = true case "rm": delete(there, args[2]) } return "", nil } d := declare(t, `{"id":"private","type":"network","name":"mail"}`) _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil) if err != nil { t.Fatal(err) } if !there["mail"] { t.Fatal("the network was not created") } // The module is unassigned: the mesh now declares nothing. empty := declare(t, `{"id":"unrelated","type":"directory","path":"`+t.TempDir()+`"}`) if _, _, err := Apply(context.Background(), archHost(t), empty, state, store.OriginDeclared, run, nil, nil); err != nil { t.Fatal(err) } if there["mail"] { t.Fatal("the network outlived the module that declared it, which is the entire reason " + "this is a shape rather than an action") } } // A network is created once and left alone when it is already there. func TestANetworkAlreadyThereIsNotRebuilt(t *testing.T) { var created int run := func(_ context.Context, name string, args ...string) (string, error) { if name == "docker" && len(args) > 1 && args[0] == "network" && args[1] == "create" { created++ } return "", nil // inspect succeeds: it is already there } d := declare(t, `{"id":"private","type":"network","name":"mail"}`) if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil); err != nil { t.Fatal(err) } if created != 0 { t.Fatalf("a network that was already there was created %d time(s); the mesh owns the "+ "name and not the thing, so it does not tear one down and rebuild it", created) } } // A secret inside a configuration file, substituted on the machine. // // **The one place a credential and a configuration meet.** A program wanting its token inside a // JSON document cannot be handed a file that is entirely a token, and the mesh cannot compose the // document because it discarded the value. So the module supplies the document with a hole, the // mesh delivers the value sealed, and the host is the only thing that ever holds both. func TestASealedValueIsPutIntoTheFileThatNamesIt(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "settings.json") d := declare(t, `{"id":"settings","type":"file","path":"`+path+`",`+ `"content":"{\"tracking\":\"on\",\"token\":\"${secret:atlassian}\"}",`+ `"secrets":{"atlassian":"SEALED"}}`) open := func(blob string) ([]byte, error) { if blob != "SEALED" { return nil, fmt.Errorf("asked to open %q", blob) } return []byte("the-real-token"), nil } if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, nil, nil, open); err != nil { t.Fatal(err) } written, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !strings.Contains(string(written), `"token":"the-real-token"`) { t.Fatalf("the secret was not put in: %s", written) } if strings.Contains(string(written), "secret:") { t.Fatalf("a placeholder survived into the file: %s", written) } // The rest of the document is untouched — this is substitution, not replacement. if !strings.Contains(string(written), `"tracking":"on"`) { t.Fatalf("the content around the secret was lost: %s", written) } // And it carries a credential, so it is not world-readable. info, err := os.Stat(path) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Errorf("a file holding a credential is %v", info.Mode().Perm()) } } // Defends the reason env-file exists: a credential may not travel in `env`. // // A declaration reaches a node over the broker and `env` is plain text in it, so a password there // is a password the broker sees. A sealed file arrives unreadable, the host writes it, and the // runtime reads it. func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) { var ran []string run := func(_ context.Context, name string, args ...string) (string, error) { ran = append(ran, name+" "+strings.Join(args, " ")) if len(args) > 0 && args[0] == "container" { return "", fmt.Errorf("no such container") } return "", nil } d := declare(t, `{"id":"app","type":"container","name":"umami",`+ `"image":"umami@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+ `"env-file":["/var/lib/umami/database.env","/var/lib/umami/app.env"]}`) _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil) var started string for _, line := range ran { if strings.Contains(line, "run ") { started = line } } for _, want := range []string{ "--env-file /var/lib/umami/database.env", "--env-file /var/lib/umami/app.env", } { if !strings.Contains(started, want) { t.Errorf("the container was started without %q:\n%s", want, started) } } } // A container may name its resolvers and its own address — the shape a module shipping its own // validating DNS needs: the resolver pinned where its siblings can find it, the siblings pointed // at it. Both flags take addresses, so both reach the runtime verbatim. func TestAContainerIsGivenItsResolverAndItsAddress(t *testing.T) { var ran []string run := func(_ context.Context, name string, args ...string) (string, error) { ran = append(ran, name+" "+strings.Join(args, " ")) if len(args) > 0 && args[0] == "container" { return "", fmt.Errorf("no such container") } return "", nil } d := declare(t, `{"id":"imap","type":"container","name":"mailu-imap",`+ `"image":"dovecot@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+ `"network":"mailu","dns":["192.168.203.254"],"ip":"192.168.203.7"}`) _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil) var started string for _, line := range ran { if strings.Contains(line, "run ") { started = line } } for _, want := range []string{"--dns 192.168.203.254", "--ip 192.168.203.7"} { if !strings.Contains(started, want) { t.Errorf("the container was started without %q:\n%s", want, started) } } }