package link import ( "crypto/ed25519" "encoding/json" "strings" "testing" "time" ) // The subject and the signing context the controller holds the same words for (mesh-controller internal/link). func TestTheSetuidSearchAskKeepsItsSubjectAndContext(t *testing.T) { if AskSetuidSearchSubject("laptop") != "mesh.node.laptop.ask.setuid-search" { t.Fatalf("the subject is %q", AskSetuidSearchSubject("laptop")) } if SetuidSearchContext != "novox-mesh setuid-search v1\n" || SetuidSearchContext == HandOverContext { t.Fatalf("the signing context is %q", SetuidSearchContext) } } func signedSearch(t *testing.T, key ed25519.PrivateKey, context string, ask HandOverAsk) []byte { t.Helper() raw, _ := json.Marshal(ask) body, _ := json.Marshal(SignedHandOver{Ask: raw, Signature: ed25519.Sign(key, append([]byte(context), raw...))}) return body } // A search ask is believed as a hand-over is: signed by the mesh under its own context (a hand-over's signature // starts no search), for this node, unexpired, once, and naming no path. func TestASetuidSearchAskIsBelievedOnlySignedForThisNodeAndOnce(t *testing.T) { m, key := aMember(t) now := time.Date(2026, 10, 10, 2, 0, 0, 0, time.UTC) var asked []string handle := func(by string) (string, error) { asked = append(asked, by); return "a new search starts", nil } check := HandOverCheck{Signer: m.Signer, Node: "n1", Now: func() time.Time { return now }, Nonces: &Nonces{}} good := HandOverAsk{Node: "n1", By: " jo ", Expires: now.Add(HandOverGood), Nonce: aNonce} for name, body := range map[string][]byte{ "a hand-over's signature": signedSearch(t, key, HandOverContext, good), "another node": signedSearch(t, key, SetuidSearchContext, HandOverAsk{Node: "n2", By: "jo", Expires: good.Expires, Nonce: aNonce}), "a path": signedSearch(t, key, SetuidSearchContext, HandOverAsk{Node: "n1", Path: "/x", By: "jo", Expires: good.Expires, Nonce: aNonce}), "expired": signedSearch(t, key, SetuidSearchContext, HandOverAsk{Node: "n1", By: "jo", Expires: now, Nonce: aNonce}), } { if a := AnswerSetuidSearch(check, handle, body); !strings.HasSuffix(a.Refused, "no search was started") || a.Said != "" { t.Errorf("%s: %+v", name, a) } } if len(asked) != 0 { t.Fatalf("a refused ask reached the handler: %v", asked) } body := signedSearch(t, key, SetuidSearchContext, good) if a := AnswerSetuidSearch(check, handle, body); a.Said != "a new search starts" || len(asked) != 1 || asked[0] != "jo" { t.Fatalf("the good ask: %+v, %v", a, asked) } if a := AnswerSetuidSearch(check, handle, body); !strings.Contains(a.Refused, "taken already") { t.Fatalf("the same ask again: %+v", a) } if a := AnswerSetuidSearch(check, nil, body); a.Refused == "" { t.Fatalf("no handler: %+v", a) } // 774 (ADR 0563 §2): the node seat's setuid-search starts none while one runs, at most one in any hour, naming when the // next may start, and records who asked. t.Run("the node seat's search is bounded", func(t *testing.T) { at, running, started := now, false, 0 h := &HandActs{Node: "n1", State: &MemState{}, Now: func() time.Time { return at }, Searching: func() bool { return running }, SetuidSearch: func(by string) (string, error) { started++; return "a new search starts", nil }} for _, row := range []struct { name string after time.Duration running bool refused string started int }{ {name: "one runs", running: true, refused: "runs on this machine now", started: 0}, {name: "the first", started: 1}, {name: "within the hour", after: 59 * time.Minute, refused: "the next may start at 2026-10-10T03:00:00Z", started: 1}, {name: "after the hour", after: time.Hour + time.Second, started: 2}, } { at, running = now.Add(row.after), row.running _, err := h.Call(EngineCall{Verb: ActSetuidSearch, Caller: "ace.node-tools"}) if (row.refused == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), row.refused)) || started != row.started { t.Errorf("%s: %v, %d started", row.name, err, started) } } acts, _ := h.Acts(1) 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) } // rev-l3 round 2 (ADR 0563 §2, a wrong caller gains nothing): judgements and refused searches, which any caller // may ask, keep one rolling key of the node's, so ten thousand of them write a bounded number of bytes and add no // key to the bucket every node shares. keysNow := func() (int, int) { all, _ := h.State.Keys("") size := 0 for _, k := range all { if e, _ := h.State.Get(k); e != nil { size += len(e.Value) } } return len(all), size } _, _ = h.Call(EngineCall{Verb: ActJudgeDirectories, Caller: "unproven"}) keysBefore, _ := keysNow() for i := range 10000 { verb := ActJudgeDirectories if i%10 == 0 { verb = ActSetuidSearch } _, _ = h.Call(EngineCall{Verb: verb, Caller: "unproven"}) } if n, size := keysNow(); n != keysBefore || size > 64<<10 { t.Errorf("ten thousand judgements and refused searches left %d keys (was %d) of %d bytes", n, keysBefore, size) } listed, _ := h.Acts(0) if got := listed.(map[string]any)["acts"].([]HandAct); len(got) == 0 || got[0].Caller != "unproven" { t.Errorf("acts does not show the latest judgement first: %d", len(got)) } }) }