From 2a0c428ef904dedeb5f2db8468c86c835920ed72 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 10:27:23 +0100 Subject: [PATCH] =?UTF-8?q?The=20last=20setuid=20search's=20start=20and=20?= =?UTF-8?q?each=20library's=20own=20take=20ask=20are=20keys=20of=20their?= =?UTF-8?q?=20own,=20which=20no=20number=20of=20other=20acts=20moves=20(is?= =?UTF-8?q?sue=20774,=20ADR=200563=20=C2=A72,=20=C2=A74;=20rev-l3=20round?= =?UTF-8?q?=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/libraries_test.go | 9 ++++ internal/link/handacts.go | 68 ++++++++++++++++++++++++----- internal/link/setuid_search_test.go | 19 ++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/cmd/mesh-host/libraries_test.go b/cmd/mesh-host/libraries_test.go index c753209..10910c4 100644 --- a/cmd/mesh-host/libraries_test.go +++ b/cmd/mesh-host/libraries_test.go @@ -83,5 +83,14 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) { if len(asked) != 1 { t.Fatalf("the take was asked again after its ask ended: %d", len(asked)) } + // Kept in a key of its own (rev-l3 round 1): the acts gone from the record never make it ask again. + keys, _ := state.Keys("act.") + for _, k := range keys { + _ = state.Delete(k) + } + askTakes(h, waiting, nil) + if len(asked) != 1 { + t.Fatalf("the take was asked again with the acts gone: %d", len(asked)) + } }) } diff --git a/internal/link/handacts.go b/internal/link/handacts.go index e4f7bf7..fb736b1 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -329,17 +329,11 @@ func (h *HandActs) Search(caller, why string) (any, error) { if h.Searching != nil && h.Searching() { return nil, errors.New("a search for setuid programs runs on this machine now; no second is started while it does") } - acts, err := h.read() - if err != nil { + // The last start is a key of its own, claimed by compare-and-set before the search starts: no number of other acts, + // nor the record of acts, moves it (ADR 0563 §2). + if err := h.claimSearch(now, caller); err != nil { return nil, err } - for i := len(acts) - 1; i >= 0; i-- { - a := acts[i] - if a.Act == ActSetuidSearch && a.State == HandDone && now.Sub(a.At) < SearchEvery { - return nil, fmt.Errorf("a search was started at %s by %s, and one is started in any hour: the next may start "+ - "at %s", a.At.Format(time.RFC3339), a.Caller, a.At.Add(SearchEvery).Format(time.RFC3339)) - } - } said, err := h.SetuidSearch(caller) a := HandAct{ID: h.id(ActSetuidSearch), Act: ActSetuidSearch, Caller: caller, Why: why, At: now, State: HandDone, Said: said} if err != nil { @@ -354,6 +348,52 @@ func (h *HandActs) Search(caller, why string) (any, error) { return map[string]any{"node": h.Node, "said": said}, nil } +// searchKey keeps the last search's start on this node, and libKey each library's own take ask: keys of their own, which +// no other act's key moves, kept for the bucket's age (ninety days, the node-engine's manifest). +const searchKey = "search.last" + +func libKey(access string) string { + sum := sha256.Sum256([]byte(access)) + return "lib." + hex.EncodeToString(sum[:12]) + ".asked" +} + +// started is the last search's start as kept, and who asked it. +type started struct { + At time.Time `json:"at"` + By string `json:"by"` +} + +// claimSearch refuses a search within SearchEvery of the last start, naming when the next may start, and otherwise +// claims this one by compare-and-set: a second claim at the same moment loses. +func (h *HandActs) claimSearch(now time.Time, caller string) error { + e, err := h.State.Get(searchKey) + if err != nil { + return err + } + var rev uint64 + if e != nil { + var last started + if err := json.Unmarshal(e.Value, &last); err != nil { + return fmt.Errorf("the last search's start cannot be read, so none is started: %w", err) + } + if now.Sub(last.At) < SearchEvery { + return fmt.Errorf("a search was started at %s by %s, and one is started in any hour: the next may start "+ + "at %s", last.At.Format(time.RFC3339), last.By, last.At.Add(SearchEvery).Format(time.RFC3339)) + } + rev = e.Revision + } + claim := started{At: now, By: caller} + if rev == 0 { + _, err = h.State.Create(searchKey, claim) + } else { + _, err = h.State.Update(searchKey, claim, rev) + } + if errors.Is(err, stdio.ErrChanged) { + return errors.New("another search was started at this moment; no second is started") + } + return err +} + // ---- the asks (§3, §4) -------------------------------------------------------------------------------------------- func (h *HandActs) id(act string) string { @@ -399,9 +439,13 @@ func (h *HandActs) AskAccess(caller, act, access, why string, auto bool) (any, e // AskedFor says whether an act on subject was ever asked by the engine itself: a library's take is asked by itself once // (ADR 0563 §4), and again only by hand. func (h *HandActs) AskedFor(act, subject string) bool { + if act == ActAccess { + e, err := h.State.Get(libKey(subject)) + return err != nil || e != nil // unread is never a reason to ask again + } acts, err := h.read() if err != nil { - return true // unread is never a reason to ask again + return true } for _, a := range acts { if a.Act == act && a.Subject == subject && (a.Auto || a.State == HandAsked) { @@ -465,6 +509,10 @@ func (h *HandActs) ask(act, caller, why string, auto bool, j Judged) (any, error _, _, _ = h.end(a.Ask, func(x *HandAct) { x.State, x.Said = HandRefused, "the operator could not be asked: "+err.Error() }) return nil, fmt.Errorf("the operator could not be asked, and nothing changed: %w", err) } + // Once asked, a library's own take ask is kept's own take ask: once, in a key of its own (ADR 0563 §4). + if auto && act == ActAccess { + _, _ = h.State.Create(libKey(j.Subject), map[string]any{"ask": a.Ask, "at": now}) + } h.say(HandAsked, a) return map[string]any{"asked": a.Ask, "node": h.Node, "says": "the operator is asked on their channel; only " + "their yes changes anything. acts shows where it stands."}, nil diff --git a/internal/link/setuid_search_test.go b/internal/link/setuid_search_test.go index 4c42053..3ff0354 100644 --- a/internal/link/setuid_search_test.go +++ b/internal/link/setuid_search_test.go @@ -86,5 +86,24 @@ func TestASetuidSearchAskIsBelievedOnlySignedForThisNodeAndOnce(t *testing.T) { if got := acts.(map[string]any)["acts"].([]HandAct); len(got) != 1 || got[0].Caller != "ace.node-tools" || got[0].State != HandDone { t.Errorf("the last search is recorded as %+v", got) } + // The bound is kept in a key of its own (rev-l3 round 1): two hundred judgements before a search, or the acts + // gone from the record, never let a second search start within the hour. + h.JudgeDirectories = func(string) (string, error) { return "judged", nil } + for range 200 { + if _, err := h.Call(EngineCall{Verb: ActJudgeDirectories, Caller: "ace.node-tools"}); err != nil { + t.Fatal(err) + } + } + at = now.Add(time.Hour + 2*time.Minute) + if _, err := h.Call(EngineCall{Verb: ActSetuidSearch, Caller: "ace.node-tools"}); err == nil || !strings.Contains(err.Error(), "the next may start") { + t.Errorf("a search after two hundred judgements: %v", err) + } + keys, _ := h.State.Keys("act.") + for _, k := range keys { + _ = h.State.Delete(k) + } + if _, err := h.Call(EngineCall{Verb: ActSetuidSearch, Caller: "ace.node-tools"}); err == nil || !strings.Contains(err.Error(), "the next may start") || started != 2 { + t.Errorf("a search with the acts gone: %v, %d started", err, started) + } }) }