diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index be28d5c0e..025eba1c4 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + "reflect" + "sort" "strings" "sync" "testing" @@ -71,6 +73,13 @@ func (m *memAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) { m.asks[k], m.revs[k] = clone(r), m.rev return m.rev, nil } +func (m *memAsks) DeleteAsk(k string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.asks, k) + delete(m.revs, k) + return nil +} func (m *memAsks) AskKeys() ([]string, error) { m.mu.Lock() defer m.mu.Unlock() @@ -601,8 +610,17 @@ func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) { if strings.Contains(all, "d-6") || store.asks["mesh-delivery.d-6"].State != StateOpen || store.asks["mesh-delivery.d-3"].State != StateHeldBack { t.Errorf("the fourth ask was refused or the oldest not held back: d-6 %s, d-3 %s", store.asks["mesh-delivery.d-6"].State, store.asks["mesh-delivery.d-3"].State) } - if _, kept := store.asks["mesh-delivery.d-1"]; kept { - t.Error("a refused ask was recorded") + // A refused ask is kept as a closed record with its outcome and words (design 47 G §570), and `asks` lists it refused. + if rec, kept := store.asks["mesh-delivery.d-1"]; !kept || rec.State != string(occ.OutcomeRefused) || rec.Warrant == nil || + !strings.Contains(rec.Warrant.Words, "an ask that destroys lasts at most") || liveState(rec.State) { + t.Errorf("a refused ask was not kept as a closed record: %+v", rec) + } + listed := false + for _, v := range h.AsksNow() { + listed = listed || (v.Key == "mesh-delivery.d-1" && v.State == "refused" && strings.HasPrefix(v.Answer, "refused: ")) + } + if !listed { + t.Error("asks does not list the refused ask") } // A node's engine is an asker under node-engine- (novox/hq ADR 0563 §3, changing ADR 0259 §2): its ask is // recorded under that name, with its own bound of open asks; a name that is no asker's is dropped. @@ -1073,3 +1091,38 @@ func TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest(t *testing.T) { t.Errorf("the ask's message was folded with another: %q", asked.Body) } } + +// Design 47 G §570: a refusal is written by a create only, so an open ask of the same id keeps its record; the twenty-first +// refusal of one asker removes its oldest, as the setting refused-kept says, each removal written in the journal. +func TestRefusedAsksAreKeptByACreateOnlyAndTheOldestGoesPastTheSetting(t *testing.T) { + h, _, _, c, store, _ := newRouter(t) + h.RefusedKept = func() int { return 3 } + open := heldAsk(c, "open-1") + submit(t, h, "mesh-delivery", open) + before := store.asks["mesh-delivery.open-1"] + same := heldAsk(c, "open-1") + same.Options[0].Level = occ.Destroy + submit(t, h, "mesh-delivery", same) + if got := store.asks["mesh-delivery.open-1"]; got.State != before.State || got.Warrant != nil { + t.Fatalf("a refusal of an open ask's id overwrote it: %+v", got) + } + for i := 1; i <= 5; i++ { + bad := heldAsk(c, fmt.Sprintf("bad-%d", i)) + bad.Options[0].Level = occ.Destroy + submit(t, h, "mesh-delivery", bad) + c.pass(time.Minute) + } + var kept []string + for k, r := range store.asks { + if r.State == string(occ.OutcomeRefused) { + kept = append(kept, k) + } + } + sort.Strings(kept) + if !reflect.DeepEqual(kept, []string{"mesh-delivery.bad-3", "mesh-delivery.bad-4", "mesh-delivery.bad-5"}) { + t.Fatalf("kept %v: the three newest refused asks of the asker", kept) + } + if h.askOf("mesh-delivery.bad-1") != nil { + t.Error("the router's own view still holds the removed refusal") + } +} diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 0cba1d7b7..74a94cbf1 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -95,6 +95,8 @@ type Identity struct { type AskStore interface { GetAsk(key string) (*AskRecord, uint64, error) CreateAsk(key string, r AskRecord) (uint64, error) + // DeleteAsk removes one ask's record: the oldest refused one past the setting refused-kept (design 47 G §570). + DeleteAsk(key string) error UpdateAsk(key string, r AskRecord, revision uint64) (uint64, error) AskKeys() ([]string, error) GetIdentity(key string) (*Identity, error) @@ -864,10 +866,70 @@ func (h *Holder) offerAsk(r *Record) { h.flush(now, false) } -// refuseAsk says to its asker that an ask was not taken, and why. +// DefaultRefusedKept is how many refused asks of one asker the router keeps when the setting refused-kept gives none. +const DefaultRefusedKept = 20 + +// refuseAsk says to its asker that an ask was not taken, and why, and keeps it under `.` as a closed record with the +// outcome refused (design 47 G §570), by a create only: an id that holds a record already, an open ask with its tickets, is +// left as it is and the refusal is said on `decided` alone. func (h *Holder) refuseAsk(asker string, a oc.Ask, why string) { h.Logf("[messenger] refused an ask from %s (%s): %s", asker, a.ID, why) - h.tell(oc.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: occ.OutcomeRefused, Words: why, At: occ.FormatTime(h.Now())}) + now := h.Now() + w := oc.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: occ.OutcomeRefused, Words: why, At: occ.FormatTime(now)} + told := h.tell(w) == nil + if h.Asks == nil || !occ.UsableID(a.ID) { + return + } + key := askKey(asker, a.ID) + if h.askOf(key) != nil { + return + } + // Only its id and subject are kept: a refused ask's words may carry what may not leave the mesh (a secret's shape), so + // none of them is written to the record; its warrant says why in the router's words. + rec := AskRecord{Asker: asker, Ask: oc.Ask{ID: a.ID, About: a.About}, State: string(occ.OutcomeRefused), Opened: now, Closed: now, Warrant: &w, Told: told} + rev, err := h.Asks.CreateAsk(key, rec) + switch { + case errors.Is(err, ErrChanged): + return + case err != nil: + h.Logf("[messenger] the refusal of %s could not be kept: %v", key, err) + return + } + h.keepAsk(key, rec, rev) + h.pruneRefused(asker) +} + +// pruneRefused removes the oldest refused records of an asker past the setting refused-kept, each removal written in the +// journal (design 47 G §570). +func (h *Holder) pruneRefused(asker string) { + most := DefaultRefusedKept + if h.RefusedKept != nil { + most = h.RefusedKept() + } + h.mu.Lock() + var keys []string + for k, e := range h.asks { + if e.rec.Asker == asker && e.rec.State == string(occ.OutcomeRefused) { + keys = append(keys, k) + } + } + sort.Slice(keys, func(i, j int) bool { + a, b := h.asks[keys[i]].rec.Closed, h.asks[keys[j]].rec.Closed + return a.Before(b) || (a.Equal(b) && keys[i] < keys[j]) + }) + h.mu.Unlock() + for len(keys) > most { + oldest := keys[0] + keys = keys[1:] + if err := h.Asks.DeleteAsk(oldest); err != nil { + h.Logf("[messenger] the refused ask %s could not be removed: %v", oldest, err) + continue + } + h.mu.Lock() + delete(h.asks, oldest) + h.mu.Unlock() + h.Logf("[messenger] removed the oldest refused ask of %s, %s: %d are kept at most (refused-kept)", asker, oldest, most) + } } // tell publishes a warrant, or an ask's end, to its asker alone, deduplicated by the ask. diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 30fe6dbdc..d87b60e68 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -330,6 +330,9 @@ type Holder struct { // CapPerHour is the most messages one channel gets an hour (the setting messages-per-hour, issue 1476); // nil, or a value below zero, is DefaultCapPerHour; 0 is no cap at all. TellCapPerHour is its own and stays fixed. CapPerHour func() int + // RefusedKept is how many refused asks of one asker are kept (the setting refused-kept, design 47 G §570); nil is + // DefaultRefusedKept. + RefusedKept func() int // Settle is how long a delivered walk holds what it touched after its end (the setting settle-seconds, novox/hq // ADR 0505 §3); nil is DefaultSettle. Settle func() time.Duration diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index e115da0f1..e74264b9f 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -39,6 +39,8 @@ type Settings struct { // SettleSeconds is how long a delivered walk still holds what it touched after its end (novox/hq ADR 0505 §3): // nil, not given, is DefaultSettle; 0 holds nothing after the end. SettleSeconds *int `json:"settle-seconds"` + // RefusedKept is how many refused asks of one asker the router keeps (design 47 G §570): nil, not given, is DefaultRefusedKept. + RefusedKept *int `json:"refused-kept"` } // zoneOf is the location a time-zone setting names, or this machine's own when it names none or one @@ -105,6 +107,17 @@ func readSettings(path string) (Settings, error) { default: return s, fmt.Errorf("settle-seconds is not a number") } + switch v := loose["refused-kept"].(type) { + case nil: + case float64: + n := int(v) + if v < 1 || v != float64(n) { + return s, fmt.Errorf("refused-kept is not a whole number of one or more") + } + s.RefusedKept = &n + default: + return s, fmt.Errorf("refused-kept is not a number") + } return s, nil } @@ -242,6 +255,7 @@ func (askStore) UpdateAsk(key string, r AskRecord, revision uint64) (uint64, err rev, err := stdio.State("asks").Update(key, r, revision) return rev, changed(err) } +func (askStore) DeleteAsk(key string) error { return stdio.State("asks").Delete(key) } func (askStore) AskKeys() ([]string, error) { return stdio.State("asks").Keys() } func (askStore) GetIdentity(key string) (*Identity, error) { e, err := stdio.State("identities").Get(key) @@ -366,6 +380,12 @@ func main() { } return DefaultCapPerHour }, + RefusedKept: func() int { + if n := settings().RefusedKept; n != nil { + return *n + } + return DefaultRefusedKept + }, Settle: func() time.Duration { if n := settings().SettleSeconds; n != nil && *n >= 0 { return time.Duration(*n) * time.Second diff --git a/modules/messenger/cmd/messenger/whole_test.go b/modules/messenger/cmd/messenger/whole_test.go index 68b3801d3..e8cc5a6a0 100644 --- a/modules/messenger/cmd/messenger/whole_test.go +++ b/modules/messenger/cmd/messenger/whole_test.go @@ -149,8 +149,9 @@ func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) { if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != occ.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") { t.Errorf("%q: sent %d, told %+v", secret, len(tg.sends), got) } - if _, kept := store.asks["mesh-controller.s-4"]; kept { - t.Errorf("%q: the refused ask was recorded", secret) + if rec, kept := store.asks["mesh-controller.s-4"]; !kept || rec.State != "refused" || rec.Ask.Whole != "" || rec.Ask.Headline != "" || + strings.Contains(fmt.Sprint(rec), "ghp_") || strings.Contains(fmt.Sprint(rec), "hunter2") { + t.Errorf("%q: the refused ask was not kept as a closed record without its words: %+v", secret, rec) } } } @@ -204,7 +205,7 @@ func TestEveryKeyAndItsWholeValueIsInTheNoteWhereTheSenderIsProven(t *testing.T) 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 { + if rec, kept := store.asks["mesh-controller.s-10"]; (kept && rec.State != "refused") || rec.Ask.WholeDetails != "" || len(tg.sends) != 1 { t.Errorf("an ask whose whole details carry a secret was taken") } } diff --git a/modules/messenger/files/settings.json b/modules/messenger/files/settings.json index d628295dc..7eaa42a2d 100644 --- a/modules/messenger/files/settings.json +++ b/modules/messenger/files/settings.json @@ -3,5 +3,6 @@ "only-what-needs-me": [], "messages-per-hour": 100, "settle-seconds": 120, + "refused-kept": 20, "readers": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]} } diff --git a/modules/messenger/module.json b/modules/messenger/module.json index c6b1e56de..f3b66dd47 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -164,6 +164,14 @@ "trusted": false, "default": 100 }, + "refused-kept": { + "type": "number", + "description": "how many refused asks of one asker the router keeps as closed records, a whole number from 1 to 1000: the twenty-first refusal of an asker removes its oldest (design 47 G section 570).", + "minimum": 1, + "maximum": 1000, + "trusted": false, + "default": 20 + }, "settle-seconds": { "type": "number", "description": "seconds a delivery that ended well still holds the messages about what it changed, so a problem that passes as the delivery settles is not sent (ADR 0505 §3); 0 holds nothing after the end.", @@ -194,7 +202,7 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", - "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds", "readers"], + "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds", "refused-kept", "readers"], "merge": "json", "template": "files/settings.json", "owner": "messenger"