diff --git a/modules/claude-code/go.mod b/modules/claude-code/go.mod index 6562a740..3ae9efa8 100644 --- a/modules/claude-code/go.mod +++ b/modules/claude-code/go.mod @@ -2,6 +2,6 @@ module claude-code go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d require gopkg.in/yaml.v3 v3.0.1 diff --git a/modules/claude-code/go.sum b/modules/claude-code/go.sum index f09ac821..e31a5b11 100644 --- a/modules/claude-code/go.sum +++ b/modules/claude-code/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7P git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/modules/desk-channel/go.mod b/modules/desk-channel/go.mod index c3cc8428..fc97ce5b 100644 --- a/modules/desk-channel/go.mod +++ b/modules/desk-channel/go.mod @@ -2,4 +2,4 @@ module deskchannel go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d diff --git a/modules/desk-channel/go.sum b/modules/desk-channel/go.sum index b5bb9911..0df2f17b 100644 --- a/modules/desk-channel/go.sum +++ b/modules/desk-channel/go.sum @@ -2,3 +2,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSm git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= 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= diff --git a/modules/mesh-knowledge/go.mod b/modules/mesh-knowledge/go.mod index 5e41d704..0d489090 100644 --- a/modules/mesh-knowledge/go.mod +++ b/modules/mesh-knowledge/go.mod @@ -3,7 +3,7 @@ module mesh-knowledge go 1.25.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d github.com/jackc/pgx/v5 v5.11.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.51.0 diff --git a/modules/mesh-knowledge/go.sum b/modules/mesh-knowledge/go.sum index 06f07d11..fdc7fe21 100644 --- a/modules/mesh-knowledge/go.sum +++ b/modules/mesh-knowledge/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf h1:ED/+RszK git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= 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= 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/modules/messenger/README.md b/modules/messenger/README.md index 0ead976d..7a70a347 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -136,7 +136,9 @@ condition's message. A warning kept quiet is also said once after a day. explanation alone — a summary, then at most three points (`asks.Glance`; an ask that does not hold to it is shown and said in the log). Everything else is the message's note (`asks.Message.Note`), which a channel shows apart and smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered; -and the asker's Details (who proposed it, a fingerprint, how to read it whole). Answered, the message is one line — +and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers +and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to +the secret rule alone, as the whole words are. Answered, the message is one line — "You chose Approve at 16:06 on Telegram · " — and the router's word on the asker is its note. **An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 04ac5d04..c98c7ace 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -172,22 +172,39 @@ func (h *Holder) openAsk(r *Record) *AskRecord { // rule, is shown. The ask's state does not matter: a message edited to say how the ask ended still shows what // was approved. func (h *Holder) wholeOf(r *Record, kind string) string { + if a := h.provenAsk(r, kind); a != nil { + return a.Whole + } + return "" +} + +// wholeDetailsOf is an ask's Details with their values whole (asks.Ask.WholeDetails), shown in the note in place of +// its Details where wholeOf would show its whole words: every value an approval sets, so the operator reads exactly +// what they approve (the review of mesh-controller #304). Empty elsewhere. +func (h *Holder) wholeDetailsOf(r *Record, kind string) string { + if a := h.provenAsk(r, kind); a != nil { + return a.WholeDetails + } + return "" +} + +// provenAsk is the ask of an ask's own record when the kind proves who answers and was given the ask's answers; +// nil otherwise. +func (h *Holder) provenAsk(r *Record, kind string) *asks.Ask { if r == nil || r.Origin != "ask" || r.Ask == "" || kind == "" || h.Bench == nil { - return "" + return nil } e := h.askOf(r.Ask) - if e == nil || e.rec.Ask.Whole == "" { - return "" - } - if !h.Bench.Promises(kind, "verified-sender") { - return "" + if e == nil || !h.Bench.Promises(kind, "verified-sender") { + return nil } for _, tk := range e.rec.Tickets { if tk.Kind == kind { - return e.rec.Ask.Whole + a := e.rec.Ask + return &a } } - return "" + return nil } // carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for @@ -474,7 +491,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { // The whole words are shown only where the sender is proven (wholeOf), so a path or an address may stand // in them; a secret's shape leaves the mesh nowhere, and an ask carrying one is refused as a whole, never // shown with its explanation alone (novox/hq issue 383). - if refusal, ok := CheckSecret(a.Whole); !ok { + if refusal, ok := CheckSecret(a.Whole + "\n" + a.WholeDetails); !ok { h.refuseAsk(asker, a, "its whole words carry "+refusal.String()+", which may not leave the mesh on any channel: withhold it") return nil } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 8b3f448a..29795a39 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -885,11 +885,17 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { note = append(note, line) } } - for _, line := range strings.Split(strings.TrimSpace(rec.Ask.Details), "\n") { + // On a kind that proves who answers, the Details whole (issue 383): every value an approval sets, held to + // the secret rule alone, as the whole words are. + details, passes := rec.Ask.Details, Check + if wd := h.wholeDetailsOf(r, kind); wd != "" { + details, passes = wd, CheckSecret + } + for _, line := range strings.Split(strings.TrimSpace(details), "\n") { if line = strings.TrimSpace(line); line == "" { continue } - if refusal, ok := Check(line); !ok { + if refusal, ok := passes(line); !ok { line = "(a line carrying " + refusal.What + " is withheld here)" } note = append(note, line) diff --git a/modules/messenger/cmd/messenger/whole_test.go b/modules/messenger/cmd/messenger/whole_test.go index a094cfe2..092b7b9b 100644 --- a/modules/messenger/cmd/messenger/whole_test.go +++ b/modules/messenger/cmd/messenger/whole_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "testing" "time" @@ -153,3 +154,57 @@ func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) { } } } + +// The review of mesh-controller #304: a proposal of five keys, one of them 300 characters long, shows two keys at +// a glance; every key and every value, whole, is in the note on a kind that proves who answers, so the operator can +// read exactly what Approve sets. The desk, which proves nothing, shows them masked. +func TestEveryKeyAndItsWholeValueIsInTheNoteWhereTheSenderIsProven(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + var long strings.Builder + long.WriteString("library=/storage") + for i := 0; long.Len() < 300; i++ { + long.WriteString(fmt.Sprintf("/album-%02d", i)) + } + value := long.String()[:300] + keys := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value} + masked := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4", "shares": "library=‹path›"} + lines := func(values map[string]string) string { + var out []string + for _, k := range []string{"a", "b", "c", "d", "shares"} { + out = append(out, "+ "+k+": "+values[k]) + } + return strings.Join(out, "\n") + } + a := proposalAsk(c, "s-9") + a.Explanation = "Changes 5 setting(s) of mounts on shanks.\n+ a: 1\n+ b: 2\nand 3 more, each whole below: c, d, shares" + a.Whole = "" + a.Details = "Fingerprint 1234 5678 9abc def0 1234 5678.\n" + lines(masked) + a.WholeDetails = "Fingerprint 1234 5678 9abc def0 1234 5678.\n" + lines(keys) + submit(t, h, "mesh-controller", a) + settle(h, c) + if len(tg.sends) != 1 { + t.Fatalf("telegram got %d messages", len(tg.sends)) + } + phone := tg.sends[0] + for k, v := range keys { + if !strings.Contains(phone.Note, "+ "+k+": "+v) { + t.Errorf("the phone's note lacks %s whole:\n%s", k, phone.Note) + } + } + if strings.Contains(phone.Body, "/storage") || strings.Count(phone.Body, "\n") > 3 { + t.Errorf("the body is not the glance:\n%s", phone.Body) + } + desk := h.sayOn(h.open["ask.mesh-controller.s-9"], "raised", "desktop") + if strings.Contains(desk.Text(), "/storage") || !strings.Contains(desk.Note, "+ shares: library=‹path›") { + t.Errorf("the desk shows a value whole, or not every key:\n%s", desk.Note) + } + // A secret's shape in the whole details refuses the ask as a whole. + b := proposalAsk(c, "s-10") + b.WholeDetails = "+ token: ghp_abcdefghijklmnopqrstuvwxyz0123456789" + submit(t, h, "mesh-controller", b) + settle(h, c) + if _, kept := store.asks["mesh-controller.s-10"]; kept || len(tg.sends) != 1 { + t.Errorf("an ask whose whole details carry a secret was taken") + } +} diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index d471d086..d03c1405 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -2,4 +2,4 @@ module messenger go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d diff --git a/modules/messenger/go.sum b/modules/messenger/go.sum index 75e71c63..f3f5b3ae 100644 --- a/modules/messenger/go.sum +++ b/modules/messenger/go.sum @@ -2,3 +2,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7P git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= 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= diff --git a/modules/telegram/README.md b/modules/telegram/README.md index c12fe8eb..f8821915 100644 --- a/modules/telegram/README.md +++ b/modules/telegram/README.md @@ -31,7 +31,7 @@ as the operator. | | fixed here | |---|---| | D1 | up to 4096 characters arrive whole; longer is cut and the cut said | -| issues 383, 639 | shown formatted (HTML), never reworded: the title bold, the body as it is with a key line in monospace, the note apart in a blockquote collapsed to its first lines; `<`, `>` and `&` escaped first, so nothing in the words is read as markup. A tap is compared by the plain words, whitespace aside | +| issues 383, 639 | shown formatted (HTML), never reworded: the title bold, the body as it is with a key line in monospace, the note apart in a blockquote collapsed to its first lines, kept whole when a message is too long (the body is cut first, and the cut said); `<`, `>` and `&` escaped first, so nothing in the words is read as markup. A tap is compared by the plain words, whitespace aside | | D4 | a warning or a clearing arrives without sound; an urgent message or a question rings | | D5 | a rate limit is waited out as told | | D6 | a refusal is a permanent failure, said to the router and not retried; an edit to the same text is a success | diff --git a/modules/telegram/cmd/telegram/render.go b/modules/telegram/cmd/telegram/render.go index 37f8af5f..9bdd257f 100644 --- a/modules/telegram/cmd/telegram/render.go +++ b/modules/telegram/cmd/telegram/render.go @@ -35,8 +35,10 @@ func escape(s string) string { // shown is a message's three parts as the chat carries them. type shown struct{ title, body, note string } -// partsOf is a message's parts, cut so that its plain words fit MaxLength and the cut said (D1): the note is cut -// first, then the body. The bound is on the words, as Telegram's is on the text once its markup is read. +// partsOf is a message's parts, cut so that its plain words fit MaxLength and the cut said (D1). The body is cut +// first: the note carries what each answer does and every value an approval sets, so it is kept whole as long as +// the title and the note fit; only then is the note cut, and that said too. The bound is on the words, as +// Telegram's is on the text once its markup is read. func partsOf(m asks.Message) shown { s := shown{title: strings.TrimSpace(m.Title), body: strings.TrimSpace(m.Body), note: strings.TrimSpace(m.Note)} over := utf8.RuneCountInString(s.plain()) - MaxLength @@ -44,21 +46,16 @@ func partsOf(m asks.Message) shown { return s } mark := utf8.RuneCountInString(cutNote) - cut := func(text string, by int) string { - r := []rune(text) - keep := max(len(r)-by-mark, 0) - return string(r[:keep]) + cutNote - } - if utf8.RuneCountInString(s.note) > over+mark { - s.note = cut(s.note, over) + if r := []rune(s.body); len(r) >= over+mark { + s.body = string(r[:len(r)-over-mark]) + cutNote return s } - s.note = "" - keep := max(MaxLength-utf8.RuneCountInString(s.title)-1-mark, 0) - if r := []rune(s.body); keep < len(r) { - s.body = string(r[:keep]) + // The body cannot make room alone: what is left of it is the cut's word, and the note is cut too. + s.body = strings.TrimPrefix(cutNote, "\n") + if over = utf8.RuneCountInString(s.plain()) - MaxLength; over > 0 { + r := []rune(s.note) + s.note = string(r[:max(len(r)-over-mark, 0)]) + cutNote } - s.body += cutNote return s } diff --git a/modules/telegram/cmd/telegram/render_test.go b/modules/telegram/cmd/telegram/render_test.go index 5ae2f9eb..a2658313 100644 --- a/modules/telegram/cmd/telegram/render_test.go +++ b/modules/telegram/cmd/telegram/render_test.go @@ -61,16 +61,26 @@ func TestTheADR0343AskFitsAGlance(t *testing.T) { } } -// A long note is cut before the body is, and the cut said; a body too long loses the note and is cut itself. -func TestALongNoteIsCutBeforeTheBody(t *testing.T) { - m := asks.Message{Title: "t", Body: "the summary", Note: strings.Repeat("n", 5000)} +// The note is kept whole when the message is too long: the body is cut first and the cut said; only a note that +// cannot fit beside the title is cut, and that said too (the review of mesh-catalog #278). +func TestALongBodyIsCutBeforeTheNote(t *testing.T) { + m := asks.Message{Title: "t", Body: strings.Repeat("b", 5000), Note: "+ shares: media=/storage/media\nFingerprint 1234."} s := partsOf(m) - if s.body != "the summary" || !strings.HasSuffix(s.note, cutNote) || len([]rune(s.plain())) != MaxLength { + if s.note != m.Note || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength { + t.Errorf("note %q, body ends %q, %d characters", s.note, s.body[len(s.body)-20:], len([]rune(s.plain()))) + } + m.Body, m.Note = "the summary", strings.Repeat("n", 5000) + s = partsOf(m) + if !strings.HasSuffix(s.note, cutNote) || !strings.Contains(s.body, "cut here") || len([]rune(s.plain())) != MaxLength { t.Errorf("body %q, note ends %q, %d characters", s.body, s.note[len(s.note)-20:], len([]rune(s.plain()))) } - m.Body = strings.Repeat("b", 5000) - s = partsOf(m) - if s.note != "" || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength { - t.Errorf("note %d, %d characters", len(s.note), len([]rune(s.plain()))) +} + +// A '<', '>' or '&' in the note is escaped like any other part: it neither breaks the blockquote nor injects. +func TestTheNoteIsEscaped(t *testing.T) { + got := partsOf(asks.Message{Title: "t", Note: "Asked by x & y "}).html() + want := "t\n\n
Asked by <b>x</b> & y </blockquote><i>
" + if got != want { + t.Errorf("%q", got) } } diff --git a/modules/telegram/go.mod b/modules/telegram/go.mod index ab5e7c21..f27b7fe6 100644 --- a/modules/telegram/go.mod +++ b/modules/telegram/go.mod @@ -2,4 +2,4 @@ module telegram go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d diff --git a/modules/telegram/go.sum b/modules/telegram/go.sum index b5bb9911..0df2f17b 100644 --- a/modules/telegram/go.sum +++ b/modules/telegram/go.sum @@ -2,3 +2,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSm git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= 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=