Keep places and accesses at the terminal, and refuse a line break in any setting
Through the settings verb a caller could place a module's directory at /etc with an owner of its own and have root hand it over at the next send, or mount any of the machine's paths into a container (hq ADR 0266). Both keys are now the terminal's and never at the machine's own trees; a plans line that acts is refused wherever its subcommand stands; and a line break in a setting, which a file it is written into reads as a directive, is refused where it is kept and where it is composed.
This commit is contained in:
@@ -414,6 +414,9 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
// word (novox/hq issue 304). Adding and changing keys needs nothing; removing one needs this.
|
||||
replace := set.Bool("replace", false, "for set: remove the keys the new layer does not name")
|
||||
history := set.Bool("history", false, "for show: the layers this one replaced, the latest first")
|
||||
// Set by the settings verb on every line it composes (novox/hq ADR 0266): a verb may not change where a
|
||||
// module's directories are placed or which of the machine's paths it reaches.
|
||||
throughVerb := set.Bool("through-verb", false, "the line came from the settings verb: places and accesses are refused")
|
||||
positionals, err := parseAround(set, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -445,6 +448,11 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *throughVerb {
|
||||
if key := terminalSettingChanged(before, values); key != "" {
|
||||
return terminalSettingRefusal(key, positionals[0], where)
|
||||
}
|
||||
}
|
||||
added, changed, removed := settingsChange(before, values)
|
||||
if len(removed) > 0 && !*replace {
|
||||
return fmt.Errorf("%s on %s: this layer would no longer set %s. A layer is replaced whole; "+
|
||||
@@ -596,6 +604,15 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if len(positionals) != 1 {
|
||||
return errors.New("settings clear <module> [--node <node>]")
|
||||
}
|
||||
if *throughVerb {
|
||||
before, _, err := inv.Layer(ctx, *node, positionals[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key := terminalSettingChanged(before, nil); key != "" {
|
||||
return terminalSettingRefusal(key, positionals[0], where)
|
||||
}
|
||||
}
|
||||
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1051,3 +1068,28 @@ func declaresTools(m catalogue.Manifest) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// terminalSettings are the keys a verb may not change (novox/hq ADR 0266). `places` says where the node-engine
|
||||
// creates and, as root, owns a module's directories, with an owner the setting names; `accesses` says which of
|
||||
// the machine's paths are mounted into a module's container. Set through a verb, either would let any caller —
|
||||
// an agent among them — have root hand it a directory, or mount one of the machine's into a container it
|
||||
// reaches. They are the operator's, at the controller's terminal.
|
||||
var terminalSettings = []string{catalogue.PlacesSetting, catalogue.AccessesSetting}
|
||||
|
||||
// terminalSettingChanged is the first of those keys a layer change would add, change or remove, or "".
|
||||
func terminalSettingChanged(before, after map[string]any) string {
|
||||
for _, key := range terminalSettings {
|
||||
was, _ := json.Marshal(before[key])
|
||||
now, _ := json.Marshal(after[key])
|
||||
if string(was) != string(now) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func terminalSettingRefusal(key, module, where string) error {
|
||||
return terminalRefusal("%s of %s on %s is set at the controller's terminal only, never through a verb: it says "+
|
||||
"where root creates and owns a module's directories, or which of the machine's paths reach its container, "+
|
||||
"and whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was changed", key, module, where)
|
||||
}
|
||||
|
||||
@@ -805,13 +805,16 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
var argv []string
|
||||
switch {
|
||||
case on("clear"):
|
||||
argv = []string{"settings", "clear", str("module")}
|
||||
argv = []string{"settings", "clear", str("module"), "--through-verb"}
|
||||
case str("values") != "":
|
||||
argv = []string{"settings", "set", str("module"), str("values")}
|
||||
// What a set removes is refused unless meant (novox/hq ADR 0217).
|
||||
if on("replace") {
|
||||
argv = append(argv, "--replace")
|
||||
}
|
||||
// Through a verb, never places or accesses (novox/hq ADR 0266): the command refuses a change to
|
||||
// either when told the line came from a verb.
|
||||
argv = append(argv, "--through-verb")
|
||||
default:
|
||||
// Neither values nor clear: the layer as it stands, which is what a caller reads before
|
||||
// replacing it (novox/hq ADR 0217) — and with history, the layers it replaced.
|
||||
@@ -1359,7 +1362,10 @@ var commandReadForms = map[string]func(rest []string) bool{
|
||||
// `plan <node>` previews a node's declaration; it sends nothing.
|
||||
"plan": func([]string) bool { return true },
|
||||
// `plans` lists and `plans <id>` shows one; `plans stop|close|go` acts.
|
||||
"plans": func(r []string) bool { return !subIn(r, "stop", "close", "go") },
|
||||
// Judged on every word, not the first: a flag before the subcommand (`plans --json go <id>`) still acts.
|
||||
"plans": func(r []string) bool {
|
||||
return !slices.ContainsFunc(r, func(w string) bool { return slices.Contains(plansActs, w) })
|
||||
},
|
||||
// `doctor` answers the last run, `probes` and `signals` describe; `doctor run` runs.
|
||||
"doctor": func(r []string) bool { return flagsOnly(r) || subIn(r, "probes", "signals") },
|
||||
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
|
||||
@@ -1380,6 +1386,9 @@ var commandReadForms = map[string]func(rest []string) bool{
|
||||
},
|
||||
}
|
||||
|
||||
// plansActs are the `plans` subcommands that act on a walk; no other word of a plans line is one of them.
|
||||
var plansActs = []string{"go", "stop", "close", "retry"}
|
||||
|
||||
// heldAtTheTerminal is a refusal of policy (novox/hq ADR 0266): the verb is known and served, and this line is
|
||||
// the operator's at the controller's terminal. Never read as a verb this binary is behind on.
|
||||
type heldAtTheTerminal struct{ msg string }
|
||||
|
||||
@@ -268,8 +268,9 @@ var accountedFlags = map[string]map[string]string{
|
||||
"self": "set by the verb from the repository's form: a path on the forge, or a URL",
|
||||
"dry-run": "withheld: a dry run answers only when the build ends, which a call cannot wait for; `command` reaches it",
|
||||
},
|
||||
"builds": {"n": "=limit"},
|
||||
"plans": {"n": "=limit", "what-if": "=repository"},
|
||||
"builds": {"n": "=limit"},
|
||||
"plans": {"n": "=limit", "what-if": "=repository"},
|
||||
"settings": {"through-verb": "set by the verb on every set and clear: places and accesses are the terminal's (novox/hq ADR 0266)"},
|
||||
// The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169).
|
||||
"durations": {
|
||||
"json": "set by the verb: the answer is data",
|
||||
|
||||
@@ -123,11 +123,11 @@ func TestTokenIsRefusedThroughAVerb(t *testing.T) {
|
||||
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
|
||||
func TestSettingsSetsOrClearsALayer(t *testing.T) {
|
||||
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
|
||||
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --node ace` {
|
||||
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --through-verb --node ace` {
|
||||
t.Fatalf("set on a machine: %v %v", argv, err)
|
||||
}
|
||||
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq", "clear": "true"})
|
||||
if strings.Join(argv, " ") != "settings clear dnsmasq" {
|
||||
if strings.Join(argv, " ") != "settings clear dnsmasq --through-verb" {
|
||||
t.Fatalf("clear for the mesh: %v", argv)
|
||||
}
|
||||
// Neither values nor clear reads the layer as it stands (novox/hq ADR 0217): what a caller reads
|
||||
@@ -369,7 +369,8 @@ func TestTheCommandVerbOnlyReads(t *testing.T) {
|
||||
"secret recover a", "secret export a", "token issue --new x", "identity show", "broker users",
|
||||
"api key", "licence show", "push anchor --why w", "assign novox m", "settings set m {}",
|
||||
"settings clear m", "module add f", "module check /etc", "module forget m", "module issue m --node a",
|
||||
"seat rename a b", "plans close p --why w", "plans go p", "doctor run", "conditions silence c --why w",
|
||||
"seat rename a b", "plans close p --why w", "plans go p", "plans retry p", "plans stop p",
|
||||
"plans --json go p", "plans -n 3 close p", "plans --what-if r retry p", "doctor run", "conditions silence c --why w",
|
||||
"retire approve x", "cleanup delete x", "delivery check", "delivery go x", "bus upgrade",
|
||||
"mirrors --record x", "mirrors --confirm", "hand-act record x --why y --cause z", "serve", "migrate",
|
||||
"prepare", "declare x", "overlay x", "facts", "merge-gate", "check-here", "build x", "rebuild x",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Where root creates and owns a module's directories, and which of the machine's paths reach its container,
|
||||
// are the controller's terminal's alone (novox/hq ADR 0266): through the settings verb, a caller who set
|
||||
// `places` to /etc with an owner of its own would have the next send hand it /etc.
|
||||
|
||||
func TestTheSettingsVerbMarksEverySetAndClearAsAVerbs(t *testing.T) {
|
||||
for _, args := range []map[string]any{
|
||||
{"module": "plex", "values": `{"places":{"data":"/etc"}}`},
|
||||
{"module": "plex", "values": `{}`, "replace": "true", "node": "home"},
|
||||
{"module": "plex", "clear": "true"},
|
||||
} {
|
||||
argv, err := argvFor("settings", args)
|
||||
if err != nil || !strings.Contains(strings.Join(argv, " "), "--through-verb") {
|
||||
t.Fatalf("%v: %v %v", args, argv, err)
|
||||
}
|
||||
}
|
||||
// The generic verb never reaches settings set or clear at all.
|
||||
for _, line := range []string{"settings set plex {}", "settings clear plex"} {
|
||||
if _, err := argvFor("command", map[string]any{"command": line}); err == nil {
|
||||
t.Fatalf("command ran %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATerminalSettingIsChangedOnlyAtTheTerminal(t *testing.T) {
|
||||
cases := []struct {
|
||||
before, after map[string]any
|
||||
want string
|
||||
}{
|
||||
{nil, map[string]any{"places": map[string]any{"data": "/etc"}}, "places"},
|
||||
{map[string]any{"accesses": map[string]any{"m": "/storage"}}, map[string]any{}, "accesses"},
|
||||
{map[string]any{"places": map[string]any{"d": "/srv/d"}}, nil, "places"},
|
||||
{map[string]any{"places": map[string]any{"d": "/srv/d"}, "a": 1.0},
|
||||
map[string]any{"places": map[string]any{"d": "/srv/d"}, "a": 2.0}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := terminalSettingChanged(c.before, c.after); got != c.want {
|
||||
t.Errorf("%v → %v: %q, want %q", c.before, c.after, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Over the real stores: the verb's line is refused for places, the terminal's is taken, and a clear through
|
||||
// the verb of a layer that places a directory is refused too.
|
||||
func TestPlacesAreRefusedThroughTheVerbAndTakenAtTheTerminal(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
|
||||
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "content": "x = ${setting:x}\n"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set := func(through bool, values string) error {
|
||||
args := []string{"set", "notes", values, "--node", "laptop"}
|
||||
if through {
|
||||
args = append(args, "--through-verb")
|
||||
}
|
||||
return settingsCommand(ctx, args)
|
||||
}
|
||||
if err := set(true, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}}}`); err == nil ||
|
||||
!strings.Contains(err.Error(), "controller's terminal only") {
|
||||
t.Fatalf("places through the verb: %v", err)
|
||||
}
|
||||
if err := set(false, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}},"x":0}`); err != nil {
|
||||
t.Fatalf("places at the terminal: %v", err)
|
||||
}
|
||||
if err := set(true, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}},"x":1}`); err != nil {
|
||||
t.Fatalf("a verb may change another key and keep places as they are: %v", err)
|
||||
}
|
||||
if err := settingsCommand(ctx, []string{"clear", "notes", "--node", "laptop", "--through-verb"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "controller's terminal only") {
|
||||
t.Fatalf("a clear through the verb took places away: %v", err)
|
||||
}
|
||||
// And never at /etc, from anywhere.
|
||||
if err := set(false, `{"places":{"data":{"path":"/etc","owner":"1000:1000"}},"x":1}`); err == nil ||
|
||||
!strings.Contains(err.Error(), "/etc") {
|
||||
t.Fatalf("a place at /etc: %v", err)
|
||||
}
|
||||
// A line break in any setting is refused where it is kept.
|
||||
if err := set(false, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}},"x":"a\nPATH=/tmp"}`); err == nil ||
|
||||
!strings.Contains(err.Error(), "line break") {
|
||||
t.Fatalf("a line break: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestTheVerbsCarryReadReplaceAndMove(t *testing.T) {
|
||||
}{
|
||||
{"settings", map[string]any{"module": "plex", "node": "home"}, []string{"settings", "show", "plex", "--node", "home"}},
|
||||
{"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}},
|
||||
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
|
||||
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace", "--through-verb"}},
|
||||
{"push", map[string]any{"node": "home", "move": "plex", "why": "w"},
|
||||
[]string{"push", "home", "--wait", "0", "--move", "plex", "--why", "w"}},
|
||||
{"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}},
|
||||
|
||||
Reference in New Issue
Block a user