diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go index c8aeefcc..5bec29d7 100644 --- a/cmd/mesh-controller/proposals.go +++ b/cmd/mesh-controller/proposals.go @@ -277,6 +277,17 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, []str lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))}) } } + // **Each line is judged as the router will judge it** (the review of #304, ADR 0347 §1 and §3): a line the + // content rule (masked) or the secret rule (whole) would refuse — a value given to a secret's name, say + // "+ password: hunter2" — is said with its key still named and its value withheld, never left for the + // router to withhold whole and unnamed. A line refused even so is the propose's refusal (judgeLines). + for i, l := range lines { + if lineLeaves(l.text, exact, machines) { + continue + } + lines[i].text = l.text[:2] + l.key + " (value withheld)" + whole = false + } all := make([]string, len(lines)) for i, l := range lines { all[i] = l.text @@ -310,6 +321,37 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, []str return out, all, whole } +// lineLeaves says whether a line may leave the mesh as the router judges it: under the content rule for the masked +// form, the secret rule alone for the whole one. +func lineLeaves(line string, exact bool, machines []string) bool { + if exact { + _, ok := outward.Secret(line, machines...) + return ok + } + _, ok := outward.Check(line, machines...) + return ok +} + +// judgeLines is the first line of an ask's details the router would refuse, or withhold, and why: masked under +// the content rule line by line, whole under the secret rule. Empty when every line may leave. +func judgeLines(details string, exact bool, machines []string) (string, string) { + for _, line := range strings.Split(details, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + if exact { + if refusal, ok := outward.Secret(line, machines...); !ok { + return line, refusal.String() + } + continue + } + if refusal, ok := outward.Check(line, machines...); !ok { + return line, refusal.String() + } + } + return "", "" +} + func layerKeys(m map[string]any) []string { keys := make([]string, 0, len(m)) for k := range m { @@ -513,6 +555,17 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error) return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %s", refusal, atTheTerminalInstead(in)) } + // So are the details (ADR 0347 §1, §3): every line the router would withhold from the note, or refuse the ask + // for, is refused here, in the controller's own words, naming the line's key — Approve never sets a value the + // operator was shown nothing of. + if line, why := judgeLines(q.Details, false, machines); line != "" { + return refuse("the change's line %q would carry %s, which the operator's note may not show; %s", + keyOfLine(line), why, atTheTerminalInstead(in)) + } + if line, why := judgeLines(q.WholeDetails, true, machines); line != "" { + return refuse("the change's line %q shown whole would carry %s, which may not leave the mesh on any channel; %s", + keyOfLine(line), why, atTheTerminalInstead(in)) + } // Fail closed, before anything is kept: no router, no grant, no channel means no ask. if pr.routerHere != nil { here, err := pr.routerHere(ctx) @@ -902,3 +955,21 @@ func pushTo(node string) string { } return node } + +// keyOfLine is what a refusal names of a line: the key of a change's line ("+ key: …"), or the line's first words. +func keyOfLine(line string) string { + if len(line) > 2 && strings.ContainsRune("+~-=", rune(line[0])) && line[1] == ' ' { + rest := line[2:] + if i := strings.IndexAny(rest, ": "); i > 0 { + rest = rest[:i] + } + if _, ok := outward.Secret(rest); !ok { + return "whose key is shaped like a secret" + } + return rest + } + if r := []rune(line); len(r) > 40 { + return string(r[:40]) + "…" + } + return line +} diff --git a/cmd/mesh-controller/proposals_test.go b/cmd/mesh-controller/proposals_test.go index 99a9bd7c..5a6f759f 100644 --- a/cmd/mesh-controller/proposals_test.go +++ b/cmd/mesh-controller/proposals_test.go @@ -919,3 +919,61 @@ func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) { t.Errorf("the cut is not said, or the note lacks the whole:\n%s\n--\n%s", q2.Whole, q2.WholeDetails) } } + +// The review of #304: a value given to a secret's name, past the second key, is said with its key named and its +// value withheld, in the note on every channel; the router has nothing left to withhold unnamed or refuse. A line +// the router would refuse even so is refused at propose, in the controller's words. +func TestASecretNamedKeyIsNamedAndItsValueWithheld(t *testing.T) { + r := newProposerRig(t) + values := map[string]any{"a": "1", "b": "2", "c": "3", "password": "hunter2"} + if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: values}); err != nil { + t.Fatal(err) + } + q := r.askSent(t) + for _, form := range []string{q.Details, q.WholeDetails, q.Explanation, q.Whole} { + if strings.Contains(form, "hunter2") { + t.Errorf("the value reaches the phone:\n%s", form) + } + } + if !strings.Contains(q.Details, "+ password (value withheld)") { + t.Errorf("the key is not named with its value withheld:\n%s", q.Details) + } + if q.WholeDetails != "" && !strings.Contains(q.WholeDetails, "+ password (value withheld)") { + t.Errorf("the whole details do not name the key:\n%s", q.WholeDetails) + } + if line, why := judgeLines(q.Details, false, nil); line != "" { + t.Errorf("the router would withhold %q: %s", line, why) + } + if line, why := judgeLines(q.WholeDetails, true, nil); line != "" { + t.Errorf("the router would refuse %q: %s", line, why) + } + if !strings.HasSuffix(q.Explanation, "\nand 2 more, each in the note: c, password") { + t.Errorf("the glance:\n%s", q.Explanation) + } + // A key shaped like a secret cannot be named on any channel: refused at propose, in the controller's words, and + // the key itself not echoed. + r2 := newProposerRig(t) + secretKey := "ghp_abcdefghijklmnopqrstuvwxyz0123456789" + _, err := r2.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", + values: map[string]any{"a": "1", "b": "2", secretKey: "3"}}) + if err == nil || !strings.Contains(err.Error(), "Nothing was proposed") || strings.Contains(err.Error(), secretKey) { + t.Errorf("a secret-shaped key: %v", err) + } + if len(r2.sent) != 0 { + t.Errorf("the refused proposal was asked") + } +} + +// keyOfLine names a change's key, and never one shaped like a secret. +func TestARefusalNamesTheKeyAndNeverASecret(t *testing.T) { + for line, want := range map[string]string{ + "+ password (value withheld)": "password", + "~ shares: ‹path› (was: none)": "shares", + "+ ghp_abcdefghijklmnopqrstuvwxyz0123456789: 1": "whose key is shaped like a secret", + "Proposed by g14 at 15:04.": "Proposed by g14 at 15:04.", + } { + if got := keyOfLine(line); got != want { + t.Errorf("%q: %q, want %q", line, got, want) + } + } +} diff --git a/go.mod b/go.mod index 4353423c..a25c10d7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-controller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/go.sum b/go.sum index 564a85a0..75618b31 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index e3ded499..5e6e1f87 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself. mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock -mesh-sdk f4745447ba6d0c4fd7dc6b1071e26ce012806233 conformance/events/module-event.json +mesh-sdk 5933eb757d443293631be2bed27a96288c2348d8 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go index 8915af85..596bb8f8 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go @@ -170,14 +170,16 @@ func (a Ask) Digest() string { canonical(&b, v) } } - if a.Whole != "" || a.Details != "" { + // Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever + // it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes — + // none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks + // encode the same (the review of mesh-sdk #25). An ask with none of them digests as before they existed, and + // one without WholeDetails as before it existed. + if a.Whole != "" || a.Details != "" || a.WholeDetails != "" { canonical(&b, a.Whole) canonical(&b, a.Details) } - // WholeDetails (novox/hq issue 383, the review of mesh-controller #304) follows only when given, so every - // ask without it digests as it did before it existed. if a.WholeDetails != "" { - canonical(&b, "whole-details") canonical(&b, a.WholeDetails) } sum := sha256.Sum256([]byte(b.String())) @@ -493,7 +495,9 @@ type Message struct { Buttons []Button `json:"buttons,omitempty"` // Note is the message's secondary words, after its body and its buttons: who asked and when, what each // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart - // where it can, and after the body where it cannot; it is never left out (novox/hq issue 639). + // where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's + // length cuts the body first and the note only when the title and the note alone do not fit, saying each + // cut (novox/hq ADR 0347 §5). Note string `json:"note,omitempty"` Urgent bool `json:"urgent,omitempty"` Silent bool `json:"silent,omitempty"` diff --git a/vendor/modules.txt b/vendor/modules.txt index 9b441225..7607b731 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d +# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op