package apply import ( "archive/tar" "bytes" "compress/gzip" "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "os" "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) } } 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) } }