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:
2026-08-30 03:22:38 +02:00
parent bc5b6e2143
commit c57087d75d
13 changed files with 1006 additions and 13 deletions
+40
View File
@@ -13,6 +13,7 @@ package apply
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -168,6 +169,10 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
return applyPackage(ctx, sys, res, run) return applyPackage(ctx, sys, res, run)
case *declaration.Container: case *declaration.Container:
return applyContainer(ctx, res, run) 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: case *declaration.Action:
return applyAction(ctx, res, run) return applyAction(ctx, res, run)
default: 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()) 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" out.Action = "unchanged"
if !existed { if !existed {
out.Action = "created" out.Action = "created"
} else if !ownedAlready {
out.Action = "updated"
} else if before.Mode().Perm() != mode.Perm() { } else if before.Mode().Perm() != mode.Perm() {
out.Action = "updated" out.Action = "updated"
out.Detail = fmt.Sprintf("mode %o to %o", before.Mode().Perm(), mode.Perm()) 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 // 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.
content := r.Content 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 // 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 // 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. // 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()) 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 { switch {
case !existed: case !existed:
out.Action = "created" out.Action = "created"
+185
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -181,7 +181,7 @@ func TestContentAndSealedTogetherIsRefused(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("a file that is both literal and sealed was accepted") 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) t.Fatalf("unhelpful refusal: %v", err)
} }
} }
+155
View File
@@ -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)
}
+18
View File
@@ -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
}
+245
View File
@@ -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)
}
}
+132 -4
View File
@@ -30,6 +30,17 @@ const (
TypePackage Type = "package" TypePackage Type = "package"
TypeContainer Type = "container" TypeContainer Type = "container"
TypeAction Type = "action" 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. // Resource is one thing that should be true of the machine.
@@ -62,6 +73,9 @@ type Directory struct {
Type Type `json:"type"` Type Type `json:"type"`
Path string `json:"path"` Path string `json:"path"`
Mode string `json:"mode,omitempty"` 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 } 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 // 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. // by looking rather than by knowing which field won.
Sealed string `json:"sealed,omitempty"` 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 // 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 == "" { if f.Path == "" {
problems = append(problems, where+": a file needs a 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+ problems = append(problems, where+
": a file has content or is sealed, not both — otherwise nobody can tell by looking "+ ": a file says what is in it exactly once, and this says it as "+
"whether what landed on the machine was the secret or the placeholder") strings.Join(said, " and ")+
" — otherwise nobody can tell by looking which one landed on the machine")
} }
return append(problems, checkMode(where, f.Mode)...) 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:<hex>".
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. // 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 // 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{} return &Container{}
case TypeAction: case TypeAction:
return &Action{} return &Action{}
case TypeUser:
return &User{}
case TypeArchive:
return &Archive{}
} }
return nil return nil
} }
@@ -295,7 +422,8 @@ func newOf(t Type) Resource {
// Vocabulary is every kind this host speaks. // Vocabulary is every kind this host speaks.
func Vocabulary() []Type { func Vocabulary() []Type {
return []Type{ return []Type{
TypeAction, TypeContainer, TypeDirectory, TypeFile, TypePackage, TypeService, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypePackage,
TypeService, TypeUser,
} }
} }
+15 -8
View File
@@ -67,9 +67,9 @@ func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
func TestAnUnknownFieldIsRefused(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. // A field the host does not know is a thing the control plane believes it asked for.
refusal := refusalFor(t, `{"declaration":1,"resources":[ 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) 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) { func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) {
// novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them. // Six of them the bootstrap uses (novox/hq 07-the-substrate.md), and removing one is a
// Asserted so that removing one is a failing test rather than a discovery during a // failing test rather than a discovery during a first-node install.
// 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{} speaks := map[Type]bool{}
for _, t := range Vocabulary() { for _, t := range Vocabulary() {
speaks[t] = true speaks[t] = true
} }
for _, want := range []Type{ for _, want := range []Type{
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
TypeUser, TypeArchive,
} { } {
if !speaks[want] { if !speaks[want] {
t.Errorf("the host no longer speaks %q", 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) t.Errorf("%q is in the vocabulary and cannot be constructed", want)
} }
} }
if len(speaks) != 6 { if len(speaks) != 8 {
t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+ 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", "control plane can express, so a change here is a decision: %s",
len(speaks), vocabulary()) len(speaks), vocabulary())
} }
+37
View File
@@ -131,3 +131,40 @@ func errText(err error) string {
} }
return err.Error() 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
}
+18
View File
@@ -80,3 +80,21 @@ func (a android) ServiceBoot(context.Context, Runner, string) (string, error) {
func (a android) SetServiceBoot(context.Context, Runner, string, string) error { func (a android) SetServiceBoot(context.Context, Runner, string, string) error {
return fmt.Errorf("%w: service", ErrUnsupported) 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")
}
+35
View File
@@ -142,3 +142,38 @@ func (arch) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) e
_, err := run(ctx, "systemctl", verb, unit) _, err := run(ctx, "systemctl", verb, unit)
return err 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
}
+60
View File
@@ -60,6 +60,61 @@ type System interface {
// ServiceBoot is "enabled" or "disabled" — whether the unit starts at boot. // ServiceBoot is "enabled" or "disabled" — whether the unit starts at boot.
ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) ServiceBoot(ctx context.Context, run Runner, unit string) (string, error)
SetServiceBoot(ctx context.Context, run Runner, unit, boot 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. // Supports reports whether this host can apply a shape.
@@ -110,6 +165,7 @@ func everyShape() []declaration.Type {
return []declaration.Type{ return []declaration.Type{
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeService, declaration.TypeDirectory, declaration.TypeFile, declaration.TypeService,
declaration.TypePackage, declaration.TypeContainer, declaration.TypeAction, declaration.TypePackage, declaration.TypeContainer, declaration.TypeAction,
declaration.TypeUser, declaration.TypeArchive,
} }
} }
@@ -120,6 +176,10 @@ func everyShape() []declaration.Type {
func portableShapes() []declaration.Type { func portableShapes() []declaration.Type {
return []declaration.Type{ return []declaration.Type{
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeAction, 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,
} }
} }
+65
View File
@@ -280,3 +280,68 @@ func TestAndroidsUnreachableAppliersFailLoudly(t *testing.T) {
t.Errorf("android's service applier did not report it as unsupported: %v", err) 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")
}
}