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
+132 -4
View File
@@ -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:<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.
//
// 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,
}
}
+15 -8
View File
@@ -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())
}