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.
316 lines
12 KiB
Go
316 lines
12 KiB
Go
package declaration
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Each test names the decision it defends (novox/hq ADR 0017). The decision here is ADR 0005,
|
|
// and the property it turns on is that unknown is REFUSED, never skipped.
|
|
|
|
func valid() string {
|
|
return `{"declaration":1,"resources":[
|
|
{"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"},
|
|
{"id":"conf","type":"file","path":"/etc/mesh/host.conf","content":"a\n","mode":"0640"},
|
|
{"id":"svc","type":"service","unit":"mesh-host.service","state":"running"}
|
|
]}`
|
|
}
|
|
|
|
func refusalFor(t *testing.T, raw string) *RefusalError {
|
|
t.Helper()
|
|
_, err := Parse([]byte(raw))
|
|
if err == nil {
|
|
t.Fatal("expected a refusal")
|
|
}
|
|
var refusal *RefusalError
|
|
if !errors.As(err, &refusal) {
|
|
t.Fatalf("expected a RefusalError, got %T: %v", err, err)
|
|
}
|
|
return refusal
|
|
}
|
|
|
|
func TestAValidDeclarationParsesInOrder(t *testing.T) {
|
|
d, err := Parse([]byte(valid()))
|
|
if err != nil {
|
|
t.Fatalf("unexpected refusal: %v", err)
|
|
}
|
|
// Order is stated, not derived. The host must not sort.
|
|
got := []string{d.Resources[0].Identity(), d.Resources[1].Identity(), d.Resources[2].Identity()}
|
|
want := []string{"etc", "conf", "svc"}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("resources reordered: %v, want %v", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
|
|
// The property everything else rests on. A host that skipped what it did not understand
|
|
// would apply most of a declaration and report success — a node that looks configured and
|
|
// is not, which is 04-ISSUES/003 with the declaration on the other side of the wire.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"ok","type":"directory","path":"/etc/mesh"},
|
|
{"id":"what","type":"blockchain","path":"/etc/mesh"}
|
|
]}`)
|
|
|
|
joined := strings.Join(refusal.Problems, "\n")
|
|
if !strings.Contains(joined, "blockchain") {
|
|
t.Errorf("the unknown type was not named: %v", refusal.Problems)
|
|
}
|
|
// And it must say what IS understood, or the reader goes to the source to find out.
|
|
if !strings.Contains(joined, "directory") || !strings.Contains(joined, "service") {
|
|
t.Errorf("the refusal does not say what this host understands: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
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","immutable":true}
|
|
]}`)
|
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "immutable") {
|
|
t.Errorf("the unknown field was not named: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) {
|
|
// The same fault in miniature: set and ignored means the control plane believes it asked
|
|
// for something the host will never do.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"svc","type":"service","unit":"a.service","state":"running","path":"/etc/x"}
|
|
]}`)
|
|
joined := strings.Join(refusal.Problems, "\n")
|
|
if !strings.Contains(joined, "path") || !strings.Contains(joined, "Refused rather than ignored") {
|
|
t.Errorf("a field a service does not use was accepted: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownVersionIsRefusedWhole(t *testing.T) {
|
|
// An older host handed a newer vocabulary must not quietly do half of it.
|
|
refusal := refusalFor(t, `{"declaration":99,"resources":[
|
|
{"id":"a","type":"directory","path":"/etc/mesh"}
|
|
]}`)
|
|
joined := strings.Join(refusal.Problems, "\n")
|
|
if !strings.Contains(joined, "99") || !strings.Contains(joined, "version 1") {
|
|
t.Errorf("the version mismatch was not stated plainly: %v", refusal.Problems)
|
|
}
|
|
// Nothing else is reported, because everything else assumes a vocabulary this host does
|
|
// not have — a list of complaints derived from the wrong grammar is noise.
|
|
if len(refusal.Problems) != 1 {
|
|
t.Errorf("expected only the version problem, got: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestEveryProblemIsReportedAtOnce(t *testing.T) {
|
|
// A declaration is generated, so a person reading a refusal is debugging the generator.
|
|
// Fixing one problem at a time and re-running to find the next wastes their afternoon.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"","type":"directory","path":"/a"},
|
|
{"id":"b","type":"file"},
|
|
{"id":"c","type":"service","unit":"x.service","state":"dancing"}
|
|
]}`)
|
|
if len(refusal.Problems) < 3 {
|
|
t.Errorf("expected every problem at once, got: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestIdentityIsRequiredAndUnique(t *testing.T) {
|
|
// Identity is what lets the store know this is the same resource it applied last time,
|
|
// which is what makes removal possible at all.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"same","type":"directory","path":"/a"},
|
|
{"id":"same","type":"directory","path":"/b"}
|
|
]}`)
|
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "already used") {
|
|
t.Errorf("a duplicate identity was accepted: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) {
|
|
// "644" and "0644" differ, and the one that looks right in a manifest is the four-digit
|
|
// form. Accepting both would make a mode mean two things.
|
|
for _, mode := range []string{"644", "0999", "rwxr-xr-x", "07777777"} {
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"/a","mode":"`+mode+`"}
|
|
]}`)
|
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "mode") {
|
|
t.Errorf("mode %q was accepted: %v", mode, refusal.Problems)
|
|
}
|
|
}
|
|
if _, err := Parse([]byte(`{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"/a","mode":"0644"}
|
|
]}`)); err != nil {
|
|
t.Errorf("a well-formed mode was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestARefusalSaysNothingWasApplied(t *testing.T) {
|
|
// The reader's first question is whether the machine was left half-changed.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[{"id":"x","type":"nope"}]}`)
|
|
if !strings.Contains(refusal.Error(), "none of it was applied") {
|
|
t.Errorf("the refusal does not say the machine is untouched: %s", refusal.Error())
|
|
}
|
|
}
|
|
|
|
func TestAnEmptyDeclarationIsAMistake(t *testing.T) {
|
|
refusalFor(t, `{"declaration":1,"resources":[]}`)
|
|
}
|
|
|
|
// --- the vocabulary the substrate bootstrap needs (novox/hq 07-the-substrate.md) ---
|
|
|
|
func TestAnActionOverTheLinkIsRefused(t *testing.T) {
|
|
// novox/hq ADR 0005. The link may push declarations of known shape and never a command to
|
|
// run. This is the boundary the whole security argument rests on, so it is asserted
|
|
// directly rather than inferred from the type list.
|
|
raw := []byte(`{"declaration":1,"resources":[
|
|
{"id":"schema","type":"action","command":["psql","-f","x.sql"],"verify":["psql","-c","select 1"]}
|
|
]}`)
|
|
|
|
if _, err := Parse(raw); err == nil {
|
|
t.Fatal("an action arriving over the link was accepted")
|
|
} else if !strings.Contains(err.Error(), "the link may not carry one") {
|
|
t.Errorf("refused for the wrong reason: %v", err)
|
|
}
|
|
|
|
// And the same bytes from the bundle are fine — the asymmetry IS the decision.
|
|
if _, err := ParseTrusted(raw); err != nil {
|
|
t.Errorf("the bundle may carry an action, and this one was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnActionWithoutVerifyIsRefused(t *testing.T) {
|
|
// An action that runs and reports success without reading anything back is the fault this
|
|
// host exists to prevent. Verify is also the idempotency check, so an action without one
|
|
// cannot be applied twice safely either.
|
|
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"schema","type":"action","command":["psql","-f","x.sql"]}
|
|
]}`))
|
|
if err == nil {
|
|
t.Fatal("an action with no verify was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "needs a verify") {
|
|
t.Errorf("refused for the wrong reason: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnImageMustBePinnedByDigest(t *testing.T) {
|
|
// novox/hq ADR 0006: reproducibility comes from pinning the identity of a thing. A bundle
|
|
// naming a tag pins nothing — it names whatever that tag points at on the day it runs.
|
|
for _, image := range []string{
|
|
"postgres:17",
|
|
"postgres",
|
|
"postgres@sha256:short",
|
|
"@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
|
} {
|
|
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"store","type":"container","name":"store","image":"` + image + `"}
|
|
]}`))
|
|
if err == nil {
|
|
t.Errorf("image %q was accepted and is not pinned", image)
|
|
}
|
|
}
|
|
|
|
good := "postgres@sha256:" + strings.Repeat("a", 64)
|
|
if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"store","type":"container","name":"store","image":"` + good + `"}
|
|
]}`)); err != nil {
|
|
t.Errorf("a properly pinned image was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
|
|
// The field-set check must cover the types added last, not only the three it was written
|
|
// for. A package that carries a `content` is a control plane believing it asked for
|
|
// something that will never happen.
|
|
for _, body := range []string{
|
|
`{"id":"p","type":"package","package":"docker","content":"x"}`,
|
|
`{"id":"p","type":"package","package":"docker","image":"x"}`,
|
|
`{"id":"c","type":"container","name":"n","image":"i@sha256:` + strings.Repeat("a", 64) + `","unit":"x.service"}`,
|
|
`{"id":"a","type":"action","command":["x"],"verify":["y"],"path":"/tmp/x"}`,
|
|
} {
|
|
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[` + body + `]}`))
|
|
if err == nil {
|
|
t.Errorf("a resource carrying a field its type does not use was accepted: %s", body)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), "Refused rather than ignored") {
|
|
t.Errorf("refused for the wrong reason: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if newOf(want) == nil {
|
|
t.Errorf("%q is in the vocabulary and cannot be constructed", want)
|
|
}
|
|
}
|
|
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())
|
|
}
|
|
}
|
|
|
|
func TestADeclarationFromDiskMayBeAnnotated(t *testing.T) {
|
|
// The bundle in this repository is mostly explanation of why each digest is what it is, and
|
|
// it could be built into a binary and not applied from disk — two readers for one file. The
|
|
// failure was `invalid character '/'`, which names the symptom and not the cause.
|
|
raw := []byte(`// why this exists
|
|
{
|
|
"declaration": 1,
|
|
// and why this resource is here
|
|
"resources": [
|
|
{"id": "f", "type": "file", "path": "/etc/x", "content": "hello"}
|
|
]
|
|
}`)
|
|
d, err := ParseFileTrusted(raw)
|
|
if err != nil {
|
|
t.Fatalf("a file with comments was refused: %v", err)
|
|
}
|
|
if len(d.Resources) != 1 {
|
|
t.Fatalf("got %d resources", len(d.Resources))
|
|
}
|
|
// And the wire format is untouched: over the link it is exactly JSON, because a format with
|
|
// a second thing to strip is a format with a second thing to disagree about.
|
|
if _, err := Parse(raw); err == nil {
|
|
t.Fatal("the link accepted a declaration with comments in it")
|
|
}
|
|
}
|
|
|
|
func TestSomethingInsideAValueIsNotAComment(t *testing.T) {
|
|
// Every image reference has a `//` in it somewhere near. Only whole lines are dropped.
|
|
d, err := ParseFileTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"/etc/x","content":"see https://example.invalid/ for why"}]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file, ok := d.Resources[0].(*File)
|
|
if !ok {
|
|
t.Fatalf("got %T", d.Resources[0])
|
|
}
|
|
if !strings.Contains(file.Content, "https://example.invalid/") {
|
|
t.Fatalf("a value was mangled: %q", file.Content)
|
|
}
|
|
}
|