From 4c965e3cf6d5142ae261830ed9f045e244d15dd0 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 02:03:12 +0200 Subject: [PATCH] =?UTF-8?q?Let=20a=20trusted=20file=20that=20names=20its?= =?UTF-8?q?=20keys=20hold=20only=20those=20keys=20at=20the=20terminal=20(h?= =?UTF-8?q?q=20issue=20586,=20ADR=200329=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module with a trusted mergeable file had its whole layer refused through the settings verb, because such a file took any key. Once a module's files name the keys they take (ADR 0326), each file takes only those, so only a trusted file's named keys are the terminal's: betterbird's tags, in a file nothing trusts, is set through the verb, while its accounts and identities stay the terminal's or the operator's warrant. A module whose files name no keys keeps the whole-layer rule. --- cmd/mesh-controller/terminal_settings_test.go | 31 +++++++++++++++++++ internal/catalogue/terminal_keys.go | 21 ++++++++++++- internal/catalogue/terminal_settings_test.go | 27 ++++++++++++++++ testdata/store-tests | 1 + 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/cmd/mesh-controller/terminal_settings_test.go b/cmd/mesh-controller/terminal_settings_test.go index a70d4ead..783ca251 100644 --- a/cmd/mesh-controller/terminal_settings_test.go +++ b/cmd/mesh-controller/terminal_settings_test.go @@ -345,6 +345,37 @@ func TestAnyKeyOfAModuleWithATrustedMergeableFileIsRefusedThroughAVerb(t *testin } } +// A module whose mergeable files name the keys they take (ADR 0326): a key of a trusted file is refused through a +// verb, and a key of a file nothing trusts is set through it (novox/hq ADR 0329 §5, issue 586: betterbird's tags). +func TestOnlyATrustedFilesNamedKeyIsRefusedThroughAVerb(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + register(t, open, catalogue.Manifest{Module: "mailer", Version: "1", + Resources: []map[string]any{ + {"id": "accounts", "type": "file", "path": "/var/lib/mailer/accounts.json", "mode": "0600", "merge": "json", + "trusted": true, "takes-settings": []any{"accounts"}, "content": `{"accounts": {}}`}, + {"id": "tags", "type": "file", "path": "/var/lib/mailer/tags.json", "mode": "0644", "merge": "json", + "trusted": false, "takes-settings": []any{"tags"}, "content": `{"tags": {}}`}}}) + if _, err := assign(ctx, open, "anchor", "mailer"); err != nil { + t.Fatal(err) + } + if err := throughVerb(t, "settings", map[string]any{"module": "mailer", "values": `{"tags":{"todo":{"name":"To do"}}}`}); err != nil { + t.Fatalf("a key of a file nothing trusts is the verb's: %v", err) + } + err := throughVerb(t, "settings", map[string]any{"module": "mailer", "replace": "true", + "values": `{"tags":{"todo":{"name":"To do"}},"accounts":{"mail":{"address":"me@mail.example"}}}`}) + if err == nil || !strings.Contains(err.Error(), "controller's terminal") || !strings.Contains(err.Error(), "accounts") { + t.Fatalf("a key of a trusted file is the terminal's: %v", err) + } + values, _, err := open.inventory.Layer(ctx, "", "mailer") + if err != nil { + t.Fatal(err) + } + if raw, _ := json.Marshal(values); string(raw) != `{"tags":{"todo":{"name":"To do"}}}` { + t.Fatalf("the layer holds the tags alone: %s", raw) + } +} + // Who owns a module's directory is said at the terminal alone (novox/hq issue 279): a directory's owner may name // the settings the module runs as, root chowns the folder to them at the next push, and through the settings verb // any caller could have set puid to 0 or to its own uid. The key is refused through every verb route even where a diff --git a/internal/catalogue/terminal_keys.go b/internal/catalogue/terminal_keys.go index d3cf47aa..7ff0db8c 100644 --- a/internal/catalogue/terminal_keys.go +++ b/internal/catalogue/terminal_keys.go @@ -33,6 +33,10 @@ import ( // 4. **A module's whole layer, when it has a mergeable file not marked `"trusted": false`** (novox/hq issue 340, // TrustedMergeable). A mergeable file takes any key a layer sets, not only those its content names, so no list // of keys covers it: an empty runtime configuration a provider reads would take a `url` of the caller's. +// **Unless the module's files name the keys they take** (novox/hq ADR 0326, ADR 0329 §5): then a mergeable +// file takes exactly its `takes-settings` and no other key, so those keys of a trusted file are terminal keys +// and the rest of the layer is not. A key no file names is stray and refused where it is set. So betterbird's +// `tags`, in a file nothing trusts, is set through the verb, while its `accounts` and `identities` are not. // 5. **Every setting a directory's owner asks for** (novox/hq issue 279): root chowns the directory to the number // at the next push, so through a verb any caller could hand itself — or uid 0 — a module's folder. Whatever // else asks for the key, a file marked `"trusted": false` included, the owner makes it the terminal's. @@ -109,8 +113,13 @@ func UnsaidTrust(m Manifest) []string { // TrustedMergeable is every mergeable file of a module not marked `"trusted": false`, by id (novox/hq issue 340). // A mergeable file takes any key a layer sets, not only those its content names: an empty runtime configuration a // provider reads takes a `url` of a caller's as surely as a declared one. So a module holding one has its whole -// layer set at the controller's terminal; a verb may read it and change nothing. +// layer set at the controller's terminal; a verb may read it and change nothing. A module whose mergeable files +// name the keys they take (ADR 0326) has none here: each such file takes only its named keys, which TerminalKeys +// holds for a trusted one (ADR 0329 §5). func TrustedMergeable(m Manifest) []string { + if namesItsKeys(m) { + return nil + } var out []string for _, r := range m.Resources { if fmt.Sprint(r["type"]) != "file" { @@ -135,6 +144,16 @@ func TrustedMergeable(m Manifest) []string { func asksFor(r map[string]any) []string { content, _ := r["content"].(string) asked := settingsUsed(content) + if how, _ := r["merge"].(string); how != "" { + if taken, names := takenKeys(r); names { + // The file takes exactly the keys it names (ADR 0326), whatever its content holds. + for key := range taken { + asked = append(asked, key) + } + sort.Strings(asked) + return asked + } + } if how, _ := r["merge"].(string); how != "" && strings.TrimSpace(content) != "" { var base map[string]any if json.Unmarshal([]byte(content), &base) == nil { diff --git a/internal/catalogue/terminal_settings_test.go b/internal/catalogue/terminal_settings_test.go index 5bee50e1..ac767cfd 100644 --- a/internal/catalogue/terminal_settings_test.go +++ b/internal/catalogue/terminal_settings_test.go @@ -166,6 +166,33 @@ func TestTerminalKeysAreDerived(t *testing.T) { } } +// A module whose mergeable files name the keys they take (ADR 0326) has no whole layer at the terminal: a +// trusted file's named keys are terminal keys, and a file nothing trusts leaves its keys to the verb (novox/hq +// ADR 0329 §5, issue 586: betterbird's tags beside its trusted accounts, identities, calendars, address books +// and filters). A module whose files name no keys keeps the whole layer at the terminal (issue 340). +func TestOnlyANamedKeyOfATrustedFileIsTheTerminals(t *testing.T) { + betterbird := Manifest{Module: "betterbird", Resources: []map[string]any{ + {"id": "prefs", "type": "file", "path": "/home/p/prefs.json", "merge": MergeJSON, "trusted": true, + TakesSettingsField: []any{"prefs"}, "content": `{"prefs": {}}`}, + {"id": "accounts", "type": "file", "path": "/home/p/accounts.json", "merge": MergeJSON, "trusted": true, + TakesSettingsField: []any{"accounts"}, "content": `{"accounts": {}}`}, + {"id": "configuration", "type": "file", "path": "/home/p/configuration.json", "merge": MergeJSON, + TakesSettingsField: []any{"identities", "calendars", "address-books", "filters"}, "content": `{}`}, + {"id": "tags", "type": "file", "path": "/home/p/tags.json", "merge": MergeJSON, "trusted": false, + TakesSettingsField: []any{"tags"}, "content": `{"tags": {}}`}}} + if got := TrustedMergeable(betterbird); len(got) != 0 { + t.Fatalf("files that name their keys put no whole layer at the terminal: %v", got) + } + if got := strings.Join(TerminalKeys(betterbird), ","); got != "places,accesses,accounts,address-books,calendars,filters,identities,prefs" { + t.Fatalf("the named keys of the trusted files, unmarked counting as trusted, and not tags: %s", got) + } + silent := Manifest{Module: "keycloak", Resources: []map[string]any{{"id": "runtime", "type": "file", + "path": "/var/lib/kc/runtime.json", "merge": MergeJSON, "content": `{}`}}} + if got := TrustedMergeable(silent); len(got) != 1 { + t.Fatalf("a file that names no keys still takes any key, so its module's layer stays the terminal's: %v", got) + } +} + // A file that asks for a setting says whether what it asks is trusted (novox/hq issue 339): `trusted` is a // boolean, on a file alone, and a file asking for a setting without it is named. func TestAFileSaysWhetherItsSettingsAreTrusted(t *testing.T) { diff --git a/testdata/store-tests b/testdata/store-tests index b20acfa1..b8e50b47 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -39,3 +39,4 @@ ./internal/inventory TestAWalksRestIsReadFromEachMachinesOwnSend ./internal/inventory TestAStoredBuildWithASilentMergeableFileIsStillReadAndComposed ./cmd/mesh-controller TestPlanAndNodeListAnOptionalRequirementLeftUnbound +./cmd/mesh-controller TestOnlyATrustedFilesNamedKeyIsRefusedThroughAVerb