diff --git a/internal/apply/apply.go b/internal/apply/apply.go
index b3f6a36..5958d18 100644
--- a/internal/apply/apply.go
+++ b/internal/apply/apply.go
@@ -13,6 +13,7 @@ package apply
import (
"context"
"crypto/sha256"
+ "encoding/base64"
"encoding/hex"
"errors"
"fmt"
@@ -168,6 +169,10 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
return applyPackage(ctx, sys, res, run)
case *declaration.Container:
return applyContainer(ctx, res, run)
+ case *declaration.User:
+ return applyUser(ctx, sys, res, run)
+ case *declaration.Archive:
+ return applyArchive(ctx, res, previous)
case *declaration.Action:
return applyAction(ctx, res, run)
default:
@@ -234,9 +239,21 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) {
return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, after.Mode().Perm(), mode.Perm())
}
+ ownedAlready, err := ownedBy(r.Path, r.Owner)
+ if err != nil {
+ return out, err
+ }
+ if !ownedAlready {
+ if err := own(r.Path, r.Owner); err != nil {
+ return out, err
+ }
+ }
+
out.Action = "unchanged"
if !existed {
out.Action = "created"
+ } else if !ownedAlready {
+ out.Action = "updated"
} else if before.Mode().Perm() != mode.Perm() {
out.Action = "updated"
out.Detail = fmt.Sprintf("mode %o to %o", before.Mode().Perm(), mode.Perm())
@@ -250,6 +267,16 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outc
// What actually goes on disk. For a sealed file the mesh never had this, and neither did
// whatever carried the declaration here.
content := r.Content
+ if r.Bytes != "" {
+ // Not text. Decoded here rather than written as base64, because what a declaration says
+ // is in a file has to be what ends up in it — a wallpaper stored as its own encoding is
+ // a wallpaper nothing can open.
+ decoded, err := base64.StdEncoding.DecodeString(r.Bytes)
+ if err != nil {
+ return out, fmt.Errorf("%s carries bytes that are not base64: %w", r.Path, err)
+ }
+ content = string(decoded)
+ }
// A secret written world-readable is a secret. The default differs from an ordinary file's
// for that reason alone; an explicit mode still wins, because a module may need its own user
// to read it and only the module knows which.
@@ -324,6 +351,19 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outc
return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, info.Mode().Perm(), mode.Perm())
}
+ // And who it belongs to. Checked before setting, so a file already owned correctly is not
+ // reported as changed on every apply — which would make every reconcile look like work.
+ ownedAlready, err := ownedBy(r.Path, r.Owner)
+ if err != nil {
+ return out, err
+ }
+ if !ownedAlready {
+ if err := own(r.Path, r.Owner); err != nil {
+ return out, err
+ }
+ contentSame = false
+ }
+
switch {
case !existed:
out.Action = "created"
diff --git a/internal/apply/archive.go b/internal/apply/archive.go
new file mode 100644
index 0000000..490891a
--- /dev/null
+++ b/internal/apply/archive.go
@@ -0,0 +1,185 @@
+package apply
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/novox/mesh-host/internal/declaration"
+ "github.com/novox/mesh-host/internal/store"
+)
+
+// A set of files, fetched by digest and unpacked.
+//
+// For what inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of
+// files inlined would make every declaration enormous and rewrite all of them when one changed.
+//
+// **This is the one place the host reaches out on its own.** Everywhere else it holds a single
+// outbound connection to the broker and fetches nothing; a container image is pulled by the
+// runtime rather than by this process. So the discipline has to be explicit and it is the same
+// one the bootstrap uses for images: **pinned by digest, and the digest is checked before
+// anything is written.** What is fetched is bytes from a network the mesh does not control, and
+// the only thing making them safe to unpack is that they hash to what was declared.
+
+// maxArchive is how much will be read before giving up.
+//
+// Because a fetch with no limit is a machine somebody can fill up from the far end. Chosen large
+// enough for a desktop theme and small enough to notice.
+const maxArchive = 512 << 20
+
+func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied) (Outcome, error) {
+ out := begin(r)
+ out.Action = "unchanged"
+
+ body, err := fetch(ctx, r.Source)
+ if err != nil {
+ return out, err
+ }
+ sum := sha256.Sum256(body)
+ got := "sha256:" + hex.EncodeToString(sum[:])
+ if got != r.Digest {
+ // Refused before a single file is written. A digest that does not match means the thing
+ // at that address is not the thing that was declared, and unpacking it would be applying
+ // something nobody reviewed.
+ return out, fmt.Errorf(
+ "%s was declared as %s and what arrived is %s; nothing was unpacked",
+ r.Source, r.Digest, got)
+ }
+ out.wrote = got
+
+ // Already what it should be. The digest is the whole identity of an archive, so a matching
+ // record means the unpacked tree came from these exact bytes.
+ if previous.Wrote == got {
+ if _, err := os.Stat(r.Path); err == nil {
+ owned, err := ownedBy(r.Path, r.Owner)
+ if err == nil && owned {
+ return out, nil
+ }
+ }
+ }
+
+ if err := os.MkdirAll(r.Path, 0o755); err != nil {
+ return out, err
+ }
+ written, err := unpack(body, r.Path)
+ if err != nil {
+ return out, err
+ }
+ if err := ownAll(r.Path, r.Owner); err != nil {
+ return out, err
+ }
+ out.Action = "updated"
+ if previous.Wrote == "" {
+ out.Action = "created"
+ }
+ out.Detail = fmt.Sprintf("%d file(s)", written)
+ return out, nil
+}
+
+func fetch(ctx context.Context, source string) ([]byte, error) {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
+ if err != nil {
+ return nil, err
+ }
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch %s: %w", source, err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%s answered %s", source, response.Status)
+ }
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxArchive+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(body) > maxArchive {
+ return nil, fmt.Errorf("%s is larger than %d bytes, which is not an archive this host "+
+ "will unpack", source, maxArchive)
+ }
+ return body, nil
+}
+
+// unpack writes a gzipped tar into a directory, refusing anything that would land outside it.
+func unpack(body []byte, into string) (int, error) {
+ zipped, err := gzip.NewReader(strings.NewReader(string(body)))
+ if err != nil {
+ return 0, fmt.Errorf("this is not a gzipped tar: %w", err)
+ }
+ defer zipped.Close()
+
+ root, err := filepath.Abs(into)
+ if err != nil {
+ return 0, err
+ }
+ reader := tar.NewReader(zipped)
+ written := 0
+ for {
+ header, err := reader.Next()
+ if err == io.EOF {
+ return written, nil
+ }
+ if err != nil {
+ return written, err
+ }
+
+ // The oldest bug in unpacking: an entry named ../../etc/passwd writes outside the
+ // directory it was unpacked into.
+ //
+ // **Refused, not sanitised.** Rewriting the name so it lands inside would put a file
+ // somewhere nobody asked for and report success — the "looks configured and is not"
+ // failure this host exists to prevent. An archive that names a path outside itself is
+ // either hostile or broken, and both want the same answer.
+ cleaned := filepath.Clean(header.Name)
+ if filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) {
+ return written, fmt.Errorf(
+ "%s names a path outside the archive; nothing more was unpacked", header.Name)
+ }
+ // And the same question asked of the result, because a name can be made to resolve
+ // outside without saying so.
+ target := filepath.Join(root, cleaned)
+ if !strings.HasPrefix(target, root+string(os.PathSeparator)) && target != root {
+ return written, fmt.Errorf(
+ "%s would land outside %s; nothing more was unpacked", header.Name, into)
+ }
+
+ switch header.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(target, os.FileMode(header.Mode)&os.ModePerm); err != nil {
+ return written, err
+ }
+ case tar.TypeReg:
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return written, err
+ }
+ file, err := os.OpenFile(target,
+ os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)&os.ModePerm)
+ if err != nil {
+ return written, err
+ }
+ if _, err := io.Copy(file, io.LimitReader(reader, maxArchive)); err != nil {
+ file.Close()
+ return written, err
+ }
+ if err := file.Close(); err != nil {
+ return written, err
+ }
+ written++
+ default:
+ // Symlinks, devices, fifos. Refused rather than skipped: a theme that needed one
+ // would silently arrive incomplete, and a device node in an archive is not something
+ // to unpack quietly onto a machine.
+ return written, fmt.Errorf(
+ "%s is a %c, and this host unpacks only files and directories",
+ header.Name, header.Typeflag)
+ }
+ }
+}
diff --git a/internal/apply/sealed_test.go b/internal/apply/sealed_test.go
index 8f6db5a..77df034 100644
--- a/internal/apply/sealed_test.go
+++ b/internal/apply/sealed_test.go
@@ -181,7 +181,7 @@ func TestContentAndSealedTogetherIsRefused(t *testing.T) {
if err == nil {
t.Fatal("a file that is both literal and sealed was accepted")
}
- if !strings.Contains(err.Error(), "not both") {
+ if !strings.Contains(err.Error(), "exactly once") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
diff --git a/internal/apply/user.go b/internal/apply/user.go
new file mode 100644
index 0000000..f61519e
--- /dev/null
+++ b/internal/apply/user.go
@@ -0,0 +1,155 @@
+package apply
+
+import (
+ "context"
+ "fmt"
+ "os"
+ osuser "os/user"
+ "path/filepath"
+ "strconv"
+
+ "github.com/novox/mesh-host/internal/declaration"
+ "github.com/novox/mesh-host/internal/system"
+)
+
+// Logins, and the files that belong to them.
+//
+// Most of what a person installs is not a service. A shell, a terminal, a chat client, a desktop
+// are a package plus configuration **in somebody's home** — so a mesh with no notion of a user
+// can manage /etc and nothing anybody looks at.
+
+// applyUser makes a login match what was declared.
+//
+// Reconciling, like everything else here: it is not told whether the user is new. Creating,
+// setting a shell and adding groups are each done only when the machine does not already agree.
+func applyUser(ctx context.Context, sys system.System, r *declaration.User, run Runner) (Outcome, error) {
+ out := begin(r)
+ out.Action = "unchanged"
+
+ login, exists, err := system.LookUpUser(ctx, system.Runner(run), r.Name)
+ if err != nil {
+ return out, err
+ }
+ if !exists {
+ if err := sys.CreateUser(ctx, system.Runner(run), r.Name, r.Home, r.Shell); err != nil {
+ return out, err
+ }
+ // Read back from the machine, not from the call that made it. A useradd that returns
+ // success and leaves no entry is exactly the failure this host takes trouble over.
+ login, exists, err = system.LookUpUser(ctx, system.Runner(run), r.Name)
+ if err != nil {
+ return out, err
+ }
+ if !exists {
+ return out, fmt.Errorf("created the user %q and the user database does not have it",
+ r.Name)
+ }
+ out.Action = "created"
+ }
+
+ // The shell, only when it differs. Absent means the host asserts nothing — a field that
+ // always asserts cannot express "leave it alone", which is the difference between managing a
+ // machine and taking it over.
+ if r.Shell != "" && login.Shell != r.Shell {
+ if err := sys.SetUserShell(ctx, system.Runner(run), r.Name, r.Shell); err != nil {
+ return out, err
+ }
+ if back, _, err := system.LookUpUser(ctx, system.Runner(run), r.Name); err != nil {
+ return out, err
+ } else if back.Shell != r.Shell {
+ return out, fmt.Errorf("set %q's shell to %q and the user database says %q",
+ r.Name, r.Shell, back.Shell)
+ }
+ if out.Action == "unchanged" {
+ out.Action = "updated"
+ }
+ }
+
+ if len(r.Groups) > 0 {
+ in, err := system.GroupsOf(ctx, system.Runner(run), r.Name)
+ if err != nil {
+ return out, err
+ }
+ already := map[string]bool{}
+ for _, g := range in {
+ already[g] = true
+ }
+ for _, want := range r.Groups {
+ if already[want] {
+ continue
+ }
+ if err := sys.AddUserToGroup(ctx, system.Runner(run), r.Name, want); err != nil {
+ return out, err
+ }
+ if out.Action == "unchanged" {
+ out.Action = "updated"
+ }
+ }
+ }
+ return out, nil
+}
+
+// own sets a path's owner, when one was declared.
+//
+// Looked up by name every time rather than cached: a user's numeric id is not stable across
+// machines, and the whole reason this exists is that the same declaration lands on several.
+func own(path, owner string) error {
+ if owner == "" {
+ return nil
+ }
+ found, err := osuser.Lookup(owner)
+ if err != nil {
+ return fmt.Errorf("%s should belong to %q and this machine has no such user: %w",
+ path, owner, err)
+ }
+ uid, err := strconv.Atoi(found.Uid)
+ if err != nil {
+ return err
+ }
+ gid, err := strconv.Atoi(found.Gid)
+ if err != nil {
+ return err
+ }
+ if err := os.Chown(path, uid, gid); err != nil {
+ return fmt.Errorf("cannot give %s to %q: %w", path, owner, err)
+ }
+ return nil
+}
+
+// ownedBy reports whether a path already belongs to a user, so applying twice changes nothing.
+func ownedBy(path, owner string) (bool, error) {
+ if owner == "" {
+ return true, nil
+ }
+ found, err := osuser.Lookup(owner)
+ if err != nil {
+ return false, nil
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return false, err
+ }
+ uid, gid, ok := ownerOf(info)
+ if !ok {
+ return false, nil
+ }
+ return strconv.Itoa(uid) == found.Uid && strconv.Itoa(gid) == found.Gid, nil
+}
+
+// ownAll gives a whole tree to a user, for an archive that was unpacked into it.
+func ownAll(root, owner string) error {
+ if owner == "" {
+ return nil
+ }
+ return filepath.Walk(root, func(path string, _ os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ return own(path, owner)
+ })
+}
+
+// ownerOf is the numeric owner of a file, where the platform reports one.
+func ownerOf(info os.FileInfo) (uid, gid int, ok bool) {
+ return statOwner(info)
+}
diff --git a/internal/apply/user_unix.go b/internal/apply/user_unix.go
new file mode 100644
index 0000000..fb6765d
--- /dev/null
+++ b/internal/apply/user_unix.go
@@ -0,0 +1,18 @@
+//go:build unix
+
+package apply
+
+import (
+ "os"
+ "syscall"
+)
+
+// statOwner reads a file's numeric owner. Split out because the field is platform-specific and
+// the rest of this package should not have to know that.
+func statOwner(info os.FileInfo) (uid, gid int, ok bool) {
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return 0, 0, false
+ }
+ return int(stat.Uid), int(stat.Gid), true
+}
diff --git a/internal/apply/vocabulary_test.go b/internal/apply/vocabulary_test.go
new file mode 100644
index 0000000..94e95ba
--- /dev/null
+++ b/internal/apply/vocabulary_test.go
@@ -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": "",
+ })
+ 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)
+ }
+}
diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go
index 3b46d05..6b60c71 100644
--- a/internal/declaration/declaration.go
+++ b/internal/declaration/declaration.go
@@ -30,6 +30,17 @@ const (
TypePackage Type = "package"
TypeContainer Type = "container"
TypeAction Type = "action"
+
+ // TypeUser is a login on the machine. Added because most of what a person actually installs
+ // is not a service: a shell, a terminal, a chat client, a desktop. All of those are a package
+ // plus configuration **in somebody's home**, and a mesh with no notion of a user can only
+ // manage /etc.
+ TypeUser Type = "user"
+
+ // TypeArchive is a set of files fetched by digest and unpacked. A desktop theme is hundreds
+ // of files; inlining them would make every declaration enormous and rewrite the lot whenever
+ // one changed.
+ TypeArchive Type = "archive"
)
// Resource is one thing that should be true of the machine.
@@ -62,6 +73,9 @@ type Directory struct {
Type Type `json:"type"`
Path string `json:"path"`
Mode string `json:"mode,omitempty"`
+ // Owner is the user this belongs to, by name. Absent means root, which is what everything
+ // managed was until users existed.
+ Owner string `json:"owner,omitempty"`
}
func (d *Directory) Identity() string { return d.ID }
@@ -96,6 +110,16 @@ type File struct {
// Exclusive with Content: a file is one or the other, so that "was this secret" is answerable
// by looking rather than by knowing which field won.
Sealed string `json:"sealed,omitempty"`
+
+ // Bytes is content that is not text, base64-encoded — a wallpaper, a font, an icon.
+ //
+ // A third way of saying what is in a file, and the three are exclusive. It would have been
+ // tempting to let Content carry base64 and add a flag, and then "what is in this file" would
+ // depend on a field somewhere else.
+ Bytes string `json:"bytes,omitempty"`
+
+ // Owner is the user this belongs to, by name. Absent means root.
+ Owner string `json:"owner,omitempty"`
}
// Secret reports whether this file arrived sealed, which is what decides both that it must be
@@ -111,14 +135,113 @@ func (f *File) validate(where string, _ bool) []string {
if f.Path == "" {
problems = append(problems, where+": a file needs a path")
}
- if f.Content != "" && f.Sealed != "" {
+ var said []string
+ for name, value := range map[string]string{
+ "content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes,
+ } {
+ if value != "" {
+ said = append(said, name)
+ }
+ }
+ if len(said) > 1 {
+ sort.Strings(said)
problems = append(problems, where+
- ": a file has content or is sealed, not both — otherwise nobody can tell by looking "+
- "whether what landed on the machine was the secret or the placeholder")
+ ": a file says what is in it exactly once, and this says it as "+
+ strings.Join(said, " and ")+
+ " — otherwise nobody can tell by looking which one landed on the machine")
}
return append(problems, checkMode(where, f.Mode)...)
}
+// User is a login on the machine.
+//
+// The thing that makes a shell, a chat client or a desktop expressible at all: each is a package
+// plus configuration in somebody's home, and until this the mesh could only own /etc.
+//
+// It also makes "zsh is my login shell" **declared state** rather than an action. `chsh` is a
+// command, the link may not carry one (novox/hq ADR 0005), and a shell that could only be set by
+// hand would be a shell the mesh cannot manage — which is most of the reason to manage a machine
+// at all.
+type User struct {
+ ID string `json:"id"`
+ Type Type `json:"type"`
+ Name string `json:"name"`
+
+ // Shell this user logs in with. Absent means the host asserts nothing and leaves whatever is
+ // there — the same rule Service.Boot follows, for the same reason: a field that always
+ // asserts cannot express "I do not care".
+ Shell string `json:"shell,omitempty"`
+
+ // Groups this user must be in. Additive: the host puts the user in these and does not remove
+ // it from others, because a machine's own groups are not the mesh's to know about.
+ Groups []string `json:"groups,omitempty"`
+
+ // Home directory. Absent means the system's default for a new user, and is not changed for
+ // one that exists — moving somebody's home is not something a declaration should do quietly.
+ Home string `json:"home,omitempty"`
+}
+
+func (u *User) Identity() string { return u.ID }
+func (u *User) Kind() Type { return TypeUser }
+func (u *User) Target() string { return u.Name }
+
+func (u *User) validate(where string, _ bool) []string {
+ var problems []string
+ if u.Name == "" {
+ problems = append(problems, where+": a user needs a name")
+ }
+ if u.Shell != "" && !strings.HasPrefix(u.Shell, "/") {
+ problems = append(problems, where+
+ ": a login shell is an absolute path, and "+u.Shell+" is not one")
+ }
+ if u.Home != "" && !strings.HasPrefix(u.Home, "/") {
+ problems = append(problems, where+": a home directory is an absolute path")
+ }
+ return problems
+}
+
+// Archive is a set of files, fetched by digest and unpacked.
+//
+// For the case inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of
+// files inlined would make every declaration enormous and rewrite all of it when one changed.
+//
+// **Pinned by digest, and the digest is checked before anything is unpacked.** The same discipline
+// the bootstrap uses for images, and for the same reason — this is fetched over a network the
+// mesh does not control, and a reference that can be made to point elsewhere is not a reference.
+type Archive struct {
+ ID string `json:"id"`
+ Type Type `json:"type"`
+ // Source is where to fetch it from.
+ Source string `json:"source"`
+ // Digest is sha256 of the archive, as "sha256:".
+ Digest string `json:"digest"`
+ // Path is the directory it is unpacked into.
+ Path string `json:"path"`
+ // Owner is the user the unpacked files belong to. Absent means root.
+ Owner string `json:"owner,omitempty"`
+}
+
+func (a *Archive) Identity() string { return a.ID }
+func (a *Archive) Kind() Type { return TypeArchive }
+func (a *Archive) Target() string { return a.Path }
+
+func (a *Archive) validate(where string, _ bool) []string {
+ var problems []string
+ if a.Source == "" {
+ problems = append(problems, where+": an archive needs somewhere to fetch it from")
+ }
+ if a.Path == "" {
+ problems = append(problems, where+": an archive needs somewhere to unpack into")
+ }
+ if !strings.HasPrefix(a.Digest, "sha256:") || len(a.Digest) != len("sha256:")+64 {
+ // Refused rather than fetched and trusted. Everything else pinned in this vocabulary is
+ // pinned by digest, and an archive that was not would be the one way in.
+ problems = append(problems, where+
+ ": an archive is pinned by digest, as sha256:<64 hex characters>")
+ }
+ return problems
+}
+
// Service is a unit the host puts into a state. It does not install the unit.
//
// Two states, and they are orthogonal rather than one scale. A unit can be enabled and stopped
@@ -288,6 +411,10 @@ func newOf(t Type) Resource {
return &Container{}
case TypeAction:
return &Action{}
+ case TypeUser:
+ return &User{}
+ case TypeArchive:
+ return &Archive{}
}
return nil
}
@@ -295,7 +422,8 @@ func newOf(t Type) Resource {
// Vocabulary is every kind this host speaks.
func Vocabulary() []Type {
return []Type{
- TypeAction, TypeContainer, TypeDirectory, TypeFile, TypePackage, TypeService,
+ TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypePackage,
+ TypeService, TypeUser,
}
}
diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go
index 08c7cb7..3ed10f7 100644
--- a/internal/declaration/declaration_test.go
+++ b/internal/declaration/declaration_test.go
@@ -67,9 +67,9 @@ func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
func TestAnUnknownFieldIsRefused(t *testing.T) {
// A field the host does not know is a thing the control plane believes it asked for.
refusal := refusalFor(t, `{"declaration":1,"resources":[
- {"id":"conf","type":"file","path":"/etc/x","content":"a","owner":"root"}
+ {"id":"conf","type":"file","path":"/etc/x","content":"a","immutable":true}
]}`)
- if !strings.Contains(strings.Join(refusal.Problems, "\n"), "owner") {
+ if !strings.Contains(strings.Join(refusal.Problems, "\n"), "immutable") {
t.Errorf("the unknown field was not named: %v", refusal.Problems)
}
}
@@ -240,16 +240,23 @@ func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
}
}
-func TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) {
- // novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them.
- // Asserted so that removing one is a failing test rather than a discovery during a
- // first-node install.
+func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) {
+ // Six of them the bootstrap uses (novox/hq 07-the-substrate.md), and removing one is a
+ // failing test rather than a discovery during a first-node install.
+ //
+ // Two were added on 2026-08-30 and the count is asserted precisely because adding one is a
+ // decision. `user` and `archive` exist because most of what a person installs is not a
+ // service: a shell, a chat client, a desktop are a package plus configuration **in
+ // somebody's home**, and a mesh with no user can only own /etc. `archive` is for the case
+ // inlining cannot serve — a theme is hundreds of files, and inlining them would rewrite all
+ // of them whenever one changed.
speaks := map[Type]bool{}
for _, t := range Vocabulary() {
speaks[t] = true
}
for _, want := range []Type{
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
+ TypeUser, TypeArchive,
} {
if !speaks[want] {
t.Errorf("the host no longer speaks %q", want)
@@ -258,8 +265,8 @@ func TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) {
t.Errorf("%q is in the vocabulary and cannot be constructed", want)
}
}
- if len(speaks) != 6 {
- t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+
+ if len(speaks) != 8 {
+ t.Errorf("the vocabulary is %d shapes rather than 8; every addition widens what a compromised "+
"control plane can express, so a change here is a decision: %s",
len(speaks), vocabulary())
}
diff --git a/internal/system/alpine.go b/internal/system/alpine.go
index 41cee0b..5c66602 100644
--- a/internal/system/alpine.go
+++ b/internal/system/alpine.go
@@ -131,3 +131,40 @@ func errText(err error) string {
}
return err.Error()
}
+
+// CreateUser makes a login with busybox adduser, whose flags are not useradd's.
+//
+// `-D` is "do not ask for a password", which is what makes it usable without a terminal. A login
+// created this way has no password and cannot be logged into over the network with one, which is
+// correct: what the mesh manages is what a login owns, never a way to become it.
+func (alpine) CreateUser(ctx context.Context, run Runner, name, home, shell string) error {
+ args := []string{"-D"}
+ if home != "" {
+ args = append(args, "-h", home)
+ }
+ if shell != "" {
+ args = append(args, "-s", shell)
+ }
+ if _, err := run(ctx, "adduser", append(args, name)...); err != nil {
+ return fmt.Errorf("cannot create the user %q: %w", name, err)
+ }
+ return nil
+}
+
+func (alpine) SetUserShell(ctx context.Context, run Runner, name, shell string) error {
+ // busybox has no usermod. `sed`-ing /etc/passwd is what the distribution's own tooling does,
+ // and chsh is the one command that exists for it everywhere.
+ if _, err := run(ctx, "chsh", "-s", shell, name); err != nil {
+ return fmt.Errorf("cannot set %q's shell to %q: %w", name, shell, err)
+ }
+ return nil
+}
+
+// AddUserToGroup uses addgroup, which on busybox takes the user and the group and is additive by
+// construction — there is no form of it that replaces the set.
+func (alpine) AddUserToGroup(ctx context.Context, run Runner, name, group string) error {
+ if _, err := run(ctx, "addgroup", name, group); err != nil {
+ return fmt.Errorf("cannot put %q in the group %q: %w", name, group, err)
+ }
+ return nil
+}
diff --git a/internal/system/android.go b/internal/system/android.go
index ec53363..b74e10b 100644
--- a/internal/system/android.go
+++ b/internal/system/android.go
@@ -80,3 +80,21 @@ func (a android) ServiceBoot(context.Context, Runner, string) (string, error) {
func (a android) SetServiceBoot(context.Context, Runner, string, string) error {
return fmt.Errorf("%w: service", ErrUnsupported)
}
+
+// Users are one of the shapes this host refuses.
+//
+// Android's user database belongs to the framework and is not something an ordinary app may
+// write. Refused with a reason rather than attempted, the same as package, service and container
+// above — and the profile says so, so the control plane never sends one.
+func (android) CreateUser(context.Context, Runner, string, string, string) error {
+ return fmt.Errorf("this host implements no users: Android's user database belongs to the " +
+ "framework and is not writable by an ordinary process")
+}
+
+func (android) SetUserShell(context.Context, Runner, string, string) error {
+ return fmt.Errorf("this host implements no users")
+}
+
+func (android) AddUserToGroup(context.Context, Runner, string, string) error {
+ return fmt.Errorf("this host implements no users")
+}
diff --git a/internal/system/arch.go b/internal/system/arch.go
index b390c7d..880fef3 100644
--- a/internal/system/arch.go
+++ b/internal/system/arch.go
@@ -142,3 +142,38 @@ func (arch) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) e
_, err := run(ctx, "systemctl", verb, unit)
return err
}
+
+// CreateUser makes a login with useradd.
+//
+// `--create-home` because a user whose home does not exist is a user nothing can be delivered
+// to, and delivering a shell's configuration is most of why the mesh knows about users at all.
+func (arch) CreateUser(ctx context.Context, run Runner, name, home, shell string) error {
+ args := []string{"--create-home"}
+ if home != "" {
+ args = append(args, "--home-dir", home)
+ }
+ if shell != "" {
+ args = append(args, "--shell", shell)
+ }
+ if _, err := run(ctx, "useradd", append(args, name)...); err != nil {
+ return fmt.Errorf("cannot create the user %q: %w", name, err)
+ }
+ return nil
+}
+
+func (arch) SetUserShell(ctx context.Context, run Runner, name, shell string) error {
+ if _, err := run(ctx, "usermod", "--shell", shell, name); err != nil {
+ return fmt.Errorf("cannot set %q's shell to %q: %w", name, shell, err)
+ }
+ return nil
+}
+
+// AddUserToGroup appends, and `--append` is the whole point: without it usermod REPLACES the
+// user's supplementary groups, so a declaration naming one group would silently remove every
+// other — including the ones that make a login able to use a machine at all.
+func (arch) AddUserToGroup(ctx context.Context, run Runner, name, group string) error {
+ if _, err := run(ctx, "usermod", "--append", "--groups", group, name); err != nil {
+ return fmt.Errorf("cannot put %q in the group %q: %w", name, group, err)
+ }
+ return nil
+}
diff --git a/internal/system/system.go b/internal/system/system.go
index c1edebb..1d5daff 100644
--- a/internal/system/system.go
+++ b/internal/system/system.go
@@ -60,6 +60,61 @@ type System interface {
// ServiceBoot is "enabled" or "disabled" — whether the unit starts at boot.
ServiceBoot(ctx context.Context, run Runner, unit string) (string, error)
SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error
+
+ // CreateUser makes a login. Home and shell may be empty, meaning the system's own defaults —
+ // a declaration that says nothing about them must not impose an opinion.
+ CreateUser(ctx context.Context, run Runner, name, home, shell string) error
+ // SetUserShell changes an existing login's shell, which is what makes "zsh is my shell"
+ // declared state rather than a command the link may not carry.
+ SetUserShell(ctx context.Context, run Runner, name, shell string) error
+ // AddUserToGroup is additive and never removes. A machine's own groups are not the mesh's to
+ // know about, and a declaration that pruned them would take away what somebody set by hand.
+ AddUserToGroup(ctx context.Context, run Runner, name, group string) error
+}
+
+// Login is what the machine's user database says about a login.
+type Login struct {
+ Home string
+ Shell string
+}
+
+// LookUpUser reads a login from the user database.
+//
+// Shared rather than per-system: `getent passwd` gives the same seven colon-separated fields
+// everywhere this host runs, and a second implementation would be a second thing to get wrong in
+// the same way.
+//
+// **Absent is an answer, an error is not.** A user database that cannot be read must not be
+// reported as "no such user" — that is absence read as fact, the exact confusion this package
+// takes trouble over elsewhere. `getent` exits 2 for "not found" and other codes for failures, so
+// the two are distinguished rather than collapsed.
+func LookUpUser(ctx context.Context, run Runner, name string) (Login, bool, error) {
+ out, err := run(ctx, "getent", "passwd", name)
+ if err != nil {
+ // getent's own convention: 2 means the key was not found, which is the only failure that
+ // means "no such user".
+ if strings.Contains(err.Error(), "exit status 2") {
+ return Login{}, false, nil
+ }
+ return Login{}, false, fmt.Errorf(
+ "the user database did not answer about %q, so nothing can be said about it: %w",
+ name, err)
+ }
+ fields := strings.Split(strings.TrimSpace(out), ":")
+ if len(fields) < 7 {
+ return Login{}, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry",
+ strings.TrimSpace(out), name)
+ }
+ return Login{Home: fields[5], Shell: fields[6]}, true, nil
+}
+
+// GroupsOf is every group a login is in.
+func GroupsOf(ctx context.Context, run Runner, name string) ([]string, error) {
+ out, err := run(ctx, "id", "-nG", name)
+ if err != nil {
+ return nil, err
+ }
+ return strings.Fields(out), nil
}
// Supports reports whether this host can apply a shape.
@@ -110,6 +165,7 @@ func everyShape() []declaration.Type {
return []declaration.Type{
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeService,
declaration.TypePackage, declaration.TypeContainer, declaration.TypeAction,
+ declaration.TypeUser, declaration.TypeArchive,
}
}
@@ -120,6 +176,10 @@ func everyShape() []declaration.Type {
func portableShapes() []declaration.Type {
return []declaration.Type{
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeAction,
+ // An archive is a file that arrives in a bundle rather than in the declaration. It needs
+ // only a filesystem and a way to fetch, so a partial host can do it; a user needs a user
+ // database it is allowed to write, which it does not have.
+ declaration.TypeArchive,
}
}
diff --git a/internal/system/system_test.go b/internal/system/system_test.go
index 5c8a312..303a3ac 100644
--- a/internal/system/system_test.go
+++ b/internal/system/system_test.go
@@ -280,3 +280,68 @@ func TestAndroidsUnreachableAppliersFailLoudly(t *testing.T) {
t.Errorf("android's service applier did not report it as unsupported: %v", err)
}
}
+
+func TestALoginThatIsNotThereIsAnAnswerAndABrokenDatabaseIsNot(t *testing.T) {
+ // The distinction this package takes trouble over everywhere else, applied to users. A user
+ // database that cannot be read must not be reported as "no such user" — absence read as
+ // fact is the fault the whole host exists to prevent.
+ notFound := func(context.Context, string, ...string) (string, error) {
+ return "", errors.New("exit status 2")
+ }
+ if _, exists, err := LookUpUser(context.Background(), notFound, "nobody"); err != nil {
+ t.Fatalf("a missing user was reported as a failure: %v", err)
+ } else if exists {
+ t.Fatal("a missing user was reported as present")
+ }
+
+ broken := func(context.Context, string, ...string) (string, error) {
+ return "", errors.New("exit status 71: cannot read /etc/passwd")
+ }
+ if _, exists, err := LookUpUser(context.Background(), broken, "somebody"); err == nil {
+ t.Fatal("a broken user database was reported as an answer")
+ } else if exists {
+ t.Fatal("a broken user database reported a user as present")
+ }
+}
+
+func TestALoginIsReadFromThePasswdEntry(t *testing.T) {
+ answering := func(context.Context, string, ...string) (string, error) {
+ return "worker:x:1001:1001:,,,:/home/worker:/usr/bin/zsh\n", nil
+ }
+ login, exists, err := LookUpUser(context.Background(), answering, "worker")
+ if err != nil || !exists {
+ t.Fatalf("exists=%v err=%v", exists, err)
+ }
+ if login.Home != "/home/worker" || login.Shell != "/usr/bin/zsh" {
+ t.Fatalf("got %+v", login)
+ }
+}
+
+func TestSomethingThatIsNotAPasswdEntryIsRefused(t *testing.T) {
+ // Rather than read as a login with empty fields, which would have the host decide the shell
+ // differs and set it on every apply for ever.
+ nonsense := func(context.Context, string, ...string) (string, error) {
+ return "who knows\n", nil
+ }
+ if _, _, err := LookUpUser(context.Background(), nonsense, "worker"); err == nil {
+ t.Fatal("nonsense was read as a login")
+ }
+}
+
+func TestAPartialHostRefusesUsersAndAllowsArchives(t *testing.T) {
+ // An archive needs a filesystem and a way to fetch; a user needs a user database this host is
+ // allowed to write, which Android does not have.
+ speaks := map[declaration.Type]bool{}
+ for _, shape := range (android{}).Shapes() {
+ speaks[shape] = true
+ }
+ if !speaks[declaration.TypeArchive] {
+ t.Error("a partial host refuses archives, which need only a filesystem")
+ }
+ if speaks[declaration.TypeUser] {
+ t.Error("a partial host claims to manage users")
+ }
+ if err := (android{}).CreateUser(context.Background(), nil, "a", "", ""); err == nil {
+ t.Error("a partial host created a user")
+ }
+}