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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user