A user, bytes, and an archive — because most of what people install is
not a service A shell, a terminal, a chat client, a desktop are a package plus configuration in somebody's home. A mesh with no notion of a user can own /etc and nothing anybody looks at, which is most of the reason to manage a machine at all. Three shapes, and the vocabulary test asserts the count precisely because widening it widens what a compromised control plane can express: user a login, its shell and its groups archive a set of files, fetched by digest and unpacked (file) gains `bytes` for what is not text, and `owner` `user` also makes "zsh is my login shell" declared state. chsh is a command, the link may not carry one, and a shell settable only by hand is a shell the mesh cannot manage. Groups are additive and never pruned — usermod without --append REPLACES them, which would silently remove every group that makes a login able to use the machine. A machine's own groups are not the mesh's to know about. The archive is the one place this host reaches out on its own; everywhere else it holds one outbound connection and fetches nothing. So it carries the discipline the bootstrap already uses for images: pinned by digest, and the digest checked before a single file is written. Two decisions in the unpacker worth naming: - an entry naming a path outside the archive is REFUSED, not sanitised. Rewriting it to land inside would put a file somewhere nobody asked for and report success. Found by the test: the first version quietly relocated it. - symlinks and device nodes are refused rather than skipped, or an archive that needed one arrives silently incomplete. A partial host does archives and refuses users: an archive needs a filesystem and a way to fetch; a user needs a user database it is allowed to write.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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": "<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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user