diff --git a/go/calladdress/calladdress.go b/go/calladdress/calladdress.go new file mode 100644 index 0000000..425ce45 --- /dev/null +++ b/go/calladdress/calladdress.go @@ -0,0 +1,108 @@ +// Package calladdress reads the key a module calls a tool or a seat verb by, as a module's invokes names it, with the +// machine it is asked of after '@': `seat:.[@]` or `.[@]` (novox/hq ADR 0159, +// ADR 0469 §3). Every caller in the SDK parses its key here before it publishes anything, so a malformed key fails at +// once and says why, never as a call nobody serves and a timeout (novox/hq issue 774, the lease write's stop asked on +// `...call.stop@novox-be...`). +package calladdress + +import ( + "fmt" + "strings" +) + +// Address is a parsed key: a seat and its verb, or a module and its tool, and the machine named, if any. +type Address struct { + Seat, Verb string + Module, Tool string + Node string +} + +// Key is the address as a module's invokes names it, without the machine. +func (a Address) Key() string { + if a.Seat != "" { + return "seat:" + a.Seat + "." + a.Verb + } + return a.Module + "." + a.Tool +} + +// Parse reads a key, refusing one with more than one '@', a node that is not one subject token, or a seat, verb, module +// or tool that holds a wildcard, white space or (but a tool) a dot. +func Parse(key string) (Address, error) { + name, node, err := cutNode(key) + if err != nil { + return Address{}, err + } + if rest, ok := strings.CutPrefix(name, "seat:"); ok { + seat, verb, ok := strings.Cut(rest, ".") + if !ok || seat == "" || verb == "" { + return Address{}, fmt.Errorf("%q names no seat and verb (seat:.)", key) + } + if err := tokens(key, seat, verb); err != nil { + return Address{}, err + } + return Address{Seat: seat, Verb: verb, Node: node}, nil + } + module, tool, ok := strings.Cut(name, ".") + if !ok || module == "" || tool == "" { + return Address{}, fmt.Errorf("%q is not ., and does not start seat:.", key) + } + if err := tokens(key, module); err != nil { + return Address{}, err + } + if err := checkTool(key, tool); err != nil { + return Address{}, err + } + return Address{Module: module, Tool: tool, Node: node}, nil +} + +// CheckOwn is Parse for a caller that may also name a tool of its own module by its bare name (the runtime's mesh/ask). +func CheckOwn(key string) error { + name, _, err := cutNode(key) + if err != nil { + return err + } + if !strings.HasPrefix(name, "seat:") && !strings.Contains(name, ".") { + return tokens(key, name) + } + _, err = Parse(key) + return err +} + +// CheckNode refuses a node name that is not one subject token. +func CheckNode(node string) error { + if node == "" || strings.ContainsAny(node, ".*>@ \t\r\n") { + return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>', '@' or space)", node) + } + return nil +} + +func cutNode(key string) (name, node string, err error) { + name, node, at := strings.Cut(key, "@") + if !at { + return key, "", nil + } + if strings.Contains(node, "@") { + return "", "", fmt.Errorf("%q holds more than one '@': a key names at most one machine", key) + } + if err := CheckNode(node); err != nil { + return "", "", fmt.Errorf("%q: %w", key, err) + } + return name, node, nil +} + +func tokens(key string, parts ...string) error { + for _, p := range parts { + if p == "" || strings.ContainsAny(p, ".*> \t\r\n") { + return fmt.Errorf("%q: %q is not one subject token (no '.', '*', '>' or space)", key, p) + } + } + return nil +} + +// checkTool refuses a tool name with a wildcard or white space; a dot is kept, as the tool runner serves it. +func checkTool(key, tool string) error { + if strings.ContainsAny(tool, "*> \t\r\n") || strings.HasPrefix(tool, ".") || strings.HasSuffix(tool, ".") { + return fmt.Errorf("%q: %q is not one subject token (no '*', '>' or space)", key, tool) + } + return nil +} diff --git a/go/calladdress/calladdress_test.go b/go/calladdress/calladdress_test.go new file mode 100644 index 0000000..2614350 --- /dev/null +++ b/go/calladdress/calladdress_test.go @@ -0,0 +1,56 @@ +package calladdress + +import ( + "strings" + "testing" +) + +// Issue 774 notes 84-87 (the lease write's stop asked of `seat:node-service-manager.stop@novox-be` published on a subject +// nobody serves and timed out after 30 s): a key names its machine after '@', and every other malformed key is refused +// before anything is published, naming why. +func TestAKeyIsParsedOrRefusedBeforeItIsPublished(t *testing.T) { + for _, row := range []struct { + key, want string // want: "seat|||", "mod|||", or a refusal's words + }{ + {"seat:node-service-manager.stop@novox", "seat|node-service-manager|stop|novox"}, + {"seat:operator-channel.tell", "seat|operator-channel|tell|"}, + {"mesh-build-go.fetch@ace", "mod|mesh-build-go|fetch|ace"}, + {"nats.nats_users", "mod|nats|nats_users|"}, + {"seat:node-service-manager.stop@novox@g14", "more than one '@'"}, + {"seat:node-service-manager.stop@", "not a node name"}, + {"seat:node-service-manager.stop@nov.ox", "not a node name"}, + {"seat:node-files.*", "not one subject token"}, + {"mesh-build-go.fe tch", "not one subject token"}, + {"mesh>build.fetch", "not one subject token"}, + {"seat:noverb", "names no seat and verb"}, + {"nodot", "is not ."}, + {"", "is not ."}, + } { + a, err := Parse(row.key) + got := "" + switch { + case err != nil: + got = err.Error() + if !strings.Contains(got, row.want) { + t.Errorf("Parse(%q) refused %q, want %q", row.key, got, row.want) + } + continue + case a.Seat != "": + got = "seat|" + a.Seat + "|" + a.Verb + "|" + a.Node + default: + got = "mod|" + a.Module + "|" + a.Tool + "|" + a.Node + } + if got != row.want { + t.Errorf("Parse(%q) = %s, want %s", row.key, got, row.want) + } + } + if a, err := Parse("seat:node-files.read@g14"); err != nil || a.Key() != "seat:node-files.read" { + t.Errorf("the key without its machine: %q, %v", a.Key(), err) + } + if err := CheckOwn("list@ace"); err != nil { + t.Errorf("a module's own tool by its bare name was refused: %v", err) + } + if err := CheckOwn("li*st"); err == nil { + t.Error("a bare name that is not one token was accepted") + } +} diff --git a/go/process/call.go b/go/process/call.go index 95cce50..6579ea7 100644 --- a/go/process/call.go +++ b/go/process/call.go @@ -11,6 +11,7 @@ import ( "github.com/nats-io/nats.go" "git.novox.be/novox/mesh-sdk/go/busretry" + "git.novox.be/novox/mesh-sdk/go/calladdress" ) // CallTimeout bounds a call to a tool or a seat verb (mesh-tools node-tools/internal/bus/bus.go:35, @@ -24,9 +25,10 @@ var CallTimeout = 30 * time.Second // verb, or "." for a module's own tool, as a process's bus.invokes names it. The reply is // {result, error, node} (mesh-tools node-tools/internal/bus/bus.go answerOf); a non-empty error is the // call's failure. It offers no way to answer a call — a process reaches the bus only as itself, and -// never serves anything. +// never serves anything. A key naming its machine after '@' (`seat:node-service-manager.stop@novox`) is CallOn of that +// machine; any other malformed key is refused before anything is published (calladdress; novox/hq issue 774). func (c *Conn) Call(key string, body any) (json.RawMessage, error) { - called, fallback, err := callSubjects(key, c.callerToken()) + called, fallback, err := callSubjectsOn(key, "", c.callerToken()) if err != nil { return nil, err } @@ -39,7 +41,7 @@ func (c *Conn) Call(key string, body any) (json.RawMessage, error) { // (mesh-tools node-tools/internal/bus/caller.go:15-19). node is one subject token: an empty one, or one // holding '.', '*', '>' or white space, is refused before anything is published. func (c *Conn) CallOn(node, key string, body any) (json.RawMessage, error) { - if err := checkNode(node); err != nil { + if err := calladdress.CheckNode(node); err != nil { return nil, err } called, fallback, err := callSubjectsOn(key, node, c.callerToken()) @@ -99,33 +101,28 @@ func callSubjects(key, token string) (called, fallback string, err error) { return callSubjectsOn(key, "", token) } -// checkNode refuses a node name that is not one subject token. -func checkNode(node string) error { - if node == "" || strings.ContainsAny(node, ".*> \t\r\n") { - return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>' or space)", node) - } - return nil -} - -// callSubjectsOn is callSubjects for one named node ("" names none): the node is a token between the tool and -// the caller on the called subject, and the last one of the fallback. +// callSubjectsOn is callSubjects for one named node ("" names none, and then the key's own '@' names it): the node is a +// token between the tool and the caller on the called subject, and the last one of the fallback. A key naming another +// machine than node is refused. func callSubjectsOn(key, node, token string) (called, fallback string, err error) { + a, err := calladdress.Parse(key) + if err != nil { + return "", "", err + } + switch { + case a.Node != "" && node != "" && a.Node != node: + return "", "", fmt.Errorf("%q names the machine %s, and it is asked of %s", key, a.Node, node) + case a.Node != "": + node = a.Node + } at := "" if node != "" { at = "." + node } - if rest, ok := strings.CutPrefix(key, "seat:"); ok { - seat, verb, ok := strings.Cut(rest, ".") - if !ok || seat == "" || verb == "" { - return "", "", fmt.Errorf("%q names no seat and verb (seat:.)", key) - } - return "mesh.seat." + seat + ".call." + verb + at + "." + token, "mesh.seat." + seat + ".tool." + verb + at, nil + if a.Seat != "" { + return "mesh.seat." + a.Seat + ".call." + a.Verb + at + "." + token, "mesh.seat." + a.Seat + ".tool." + a.Verb + at, nil } - module, tool, ok := strings.Cut(key, ".") - if !ok || module == "" || tool == "" { - return "", "", fmt.Errorf("%q is not ., and does not start seat:.", key) - } - return "mesh.mod." + module + ".call." + tool + at + "." + token, "mesh.mod." + module + ".tool." + tool + at, nil + return "mesh.mod." + a.Module + ".call." + a.Tool + at + "." + token, "mesh.mod." + a.Module + ".tool." + a.Tool + at, nil } // request asks one subject once and reads its reply ({result, error, node}); a reply naming an error is diff --git a/go/process/call_integration_test.go b/go/process/call_integration_test.go index 708e38f..09345a4 100644 --- a/go/process/call_integration_test.go +++ b/go/process/call_integration_test.go @@ -237,7 +237,8 @@ func TestReleaseAcceptanceAProcessDoesExactlyItsBusDeclaration(t *testing.T) { } // 1470/AC4: two answerers of the same verb on the subjects of nodes ace and g14; CallOn naming g14 gets g14's -// answer, and ace's answerer hears nothing. +// answer, and ace's answerer hears nothing. Issue 774 notes 84-87: Call with the key `@g14` is published on the +// same subject, never on one holding the '@' that nobody serves. func TestCallOnReachesTheNamedNodeAlone(t *testing.T) { url, account := grantedSendProcess(t) adminNC, _ := adminJS(t, url) @@ -271,6 +272,9 @@ func TestCallOnReachesTheNamedNodeAlone(t *testing.T) { if string(result) != `{"node":"g14"}` { t.Fatalf("result: %s, want g14's answer", result) } + if result, err = conn.Call("seat:operator-channel.tell@g14", map[string]any{}); err != nil || string(result) != `{"node":"g14"}` { + t.Fatalf("Call with @g14: %s, %v, want g14's answer", result, err) + } select { case <-aceHeard: t.Error("ace's answerer heard a call named for g14") diff --git a/go/process/call_test.go b/go/process/call_test.go index a892874..e56ed26 100644 --- a/go/process/call_test.go +++ b/go/process/call_test.go @@ -73,17 +73,31 @@ func TestCallSubjectsOnANodeForASeatVerb(t *testing.T) { if called != "mesh.seat.node-files.call.read.g14.tok" || fallback != "mesh.seat.node-files.tool.read.g14" { t.Errorf("called %q, fallback %q", called, fallback) } + // Issue 774 notes 84-87: the machine named in the key after '@' is the same subject, never a token holding '@'. + called, fallback, err = callSubjects("seat:node-files.read@g14", "tok") + if err != nil || called != "mesh.seat.node-files.call.read.g14.tok" || fallback != "mesh.seat.node-files.tool.read.g14" { + t.Errorf("@g14: called %q, fallback %q, %v", called, fallback, err) + } + if _, _, err := callSubjectsOn("seat:node-files.read@ace", "g14", "tok"); err == nil { + t.Error("a key naming ace was asked of g14") + } } // 1470/AC3: a node that is not one token is refused before anything is published (the Conn has no // connection, so only the name check can answer with this error). func TestCallOnRefusesANodeThatIsNotOneToken(t *testing.T) { c := &Conn{} - for _, node := range []string{"", "a.b", "*", ">", "a b", "a\tb"} { + for _, node := range []string{"", "a.b", "*", ">", "a b", "a\tb", "a@b"} { if _, err := c.CallOn(node, "mesh-build-go.fetch", nil); err == nil || !strings.Contains(err.Error(), "not a node name") { t.Errorf("CallOn(%q) = %v, want the refusal of the name", node, err) } } + // Issue 774 notes 84-87: Call refuses a malformed key the same way, before anything is published. + for _, key := range []string{"seat:node-service-manager.stop@novox-be.lan", "seat:a.b@x@y", "seat:a.*", "a.b@"} { + if _, err := c.Call(key, nil); err == nil { + t.Errorf("Call(%q) was published", key) + } + } } // The review of 2026-10-30 (issue 774): a callee's own answer is delivered, whatever its words, and is never asked diff --git a/go/stdio.go b/go/stdio.go index 71ab95b..62d0053 100644 --- a/go/stdio.go +++ b/go/stdio.go @@ -24,6 +24,8 @@ import ( "strings" "sync" "time" + + "git.novox.be/novox/mesh-sdk/go/calladdress" ) // Protocol is the MCP version this speaks; the runtime says the same. @@ -471,8 +473,12 @@ func Subscribe(pattern string, handle func(Envelope) error) error { } // Ask calls a tool on the mesh as this bundle's module, through the runtime (ADR 0198), and returns -// its result as JSON. The key is `.` as the runtime calls it. +// its result as JSON. The key is `.` or `seat:.`, with `@` for one machine's, as the +// runtime calls it; a malformed key is refused here, before the runtime is asked (calladdress; novox/hq issue 774). func Ask(key string, body any) (json.RawMessage, error) { + if err := calladdress.CheckOwn(key); err != nil { + return nil, err + } c, err := live() if err != nil { return nil, err diff --git a/go/stdio_test.go b/go/stdio_test.go index e882534..29a64db 100644 --- a/go/stdio_test.go +++ b/go/stdio_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "strings" "sync" "testing" "time" @@ -160,6 +161,12 @@ func TestABundleSubscribesAndAsksThroughTheRuntime(t *testing.T) { if !TopicMatches("a.#", "a.b.c") || TopicMatches("a.*", "a.b.c") || !TopicMatches("#", "x") { t.Error("topic matching differs from the TypeScript SDK's") } + // Issue 774 notes 84-87: a malformed key is refused before the runtime is asked. + for _, key := range []string{"seat:node-service-manager.stop@novox@g14", "seat:a.*", "postgres.list@", "postgres.list@a.b"} { + if _, err := Ask(key, nil); err == nil || strings.Contains(err.Error(), "Serve") { + t.Errorf("Ask(%q) = %v, want the key refused", key, err) + } + } } // A subscribe the runtime refused leaves no handler behind: asked again until the runtime takes it, a