Registry-wide hardening of the `mcp-name:` ownership-token match, plus the cargo follow-up fixes from the #1330 review — consolidated here per maintainer request. Rebased onto current `main` (the merged #1330 cargo commit drops out), so the diff is just the net-new work. ### Changes - **PyPI/NuGet: boundary-anchored token match.** Replace `strings.Contains(readme, "mcp-name: "+name)` with the shared `containsMCPNameToken`, so a README declaring a longer name (`…/widget-pro`) no longer satisfies an ownership claim for a shorter prefix (`…/widget`). (Cargo already got this in #1330; NPM is unaffected — it compares an exact metadata field.) - **Matcher: treat the HTML comment close as a boundary.** `<!-- mcp-name: NAME-->` / `<!--mcp-name: NAME-->` (any spacing) validate again — the documented hidden-comment form for PyPI/NuGet — while a genuine longer name (`…/widget--pro`) still does not. - **Cargo hardening (review of #1330):** pin scheme+port (not just host) on the README fetch and redirects; a rate-limited/failed crate-version existence probe now reports *transient* instead of "not found"; a 403 with the crate present no longer flatly asserts "no README". - **Docs:** note the token must be followed by a boundary. ### ⚠️ Behavior change for PyPI/NuGet — read before merging The match is **strictly stricter** (verified by fuzz, 2.3M execs: it can only flip pass→fail, never fail→pass). After the comment-close fix, the **only** forms that newly fail are unusual *inline* ones: the token ending a sentence (`…/my-mcp.`) or glued to a trailing `/`. The documented forms (own line, in `<!-- … -->`) are unaffected. **Correcting an earlier claim:** this re-validates on **edits too**, not just new versions — `edit.go → UpdateServer → ValidateUpdateRequest → validateRegistryOwnership` runs the token check on any edit of a live server (only delete-transitions skip it). So an existing PyPI/NuGet server whose README uses one of the breaking inline forms would fail on its next publish *or* edit. Given the v0.1 API-freeze posture, this should land as a deliberate, noted change. ### Testing `go build`, `vet`, `gofmt`, `golangci-lint` (prod files), `check-schema`, `validate-examples` all clean. Hermetic + **live** PyPI/NuGet/cargo suite passes. New: `TestCargoURLAllowed`, a combined-fixture probe-429→transient case, comment-form matcher cases, and `FuzzContainsMCPNameToken` (the strictly-stricter property, 2.3M execs). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
4.6 KiB
Go
104 lines
4.6 KiB
Go
package registries
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestContainsMCPNameToken covers the boundary-anchored ownership-token match
|
|
// shared by the PyPI, NuGet, and Cargo validators — in particular that a server
|
|
// name which is a prefix of a longer declared name does not satisfy the match.
|
|
func TestContainsMCPNameToken(t *testing.T) {
|
|
const name = "io.github.acme/widget"
|
|
|
|
cases := []struct {
|
|
desc string
|
|
content string
|
|
want bool
|
|
}{
|
|
{"exact on its own line", "intro text\nmcp-name: io.github.acme/widget\nmore text", true},
|
|
{"exact at end of content", "mcp-name: io.github.acme/widget", true},
|
|
{"followed by HTML tag", "<p>mcp-name: io.github.acme/widget</p>", true},
|
|
{"followed by space", "mcp-name: io.github.acme/widget is the name", true},
|
|
{"longer hyphenated name not a match", "mcp-name: io.github.acme/widget-pro\n", false},
|
|
{"longer dotted name not a match", "mcp-name: io.github.acme/widget.core\n", false},
|
|
{"longer slashed name not a match", "mcp-name: io.github.acme/widget/sub\n", false},
|
|
{"absent", "nothing to see here", false},
|
|
{"different name", "mcp-name: io.github.other/thing\n", false},
|
|
{"prefix occurrence before a real one still matches", "mcp-name: io.github.acme/widget-pro then mcp-name: io.github.acme/widget\n", true},
|
|
|
|
// HTML hidden-comment form (documented for PyPI/NuGet). The canonical
|
|
// spaced form has always passed; the no-space variants must pass too,
|
|
// since the byte after the name is the `-` of the comment close.
|
|
{"comment, spaced (canonical)", "<!-- mcp-name: io.github.acme/widget -->", true},
|
|
{"comment, no trailing space", "<!-- mcp-name: io.github.acme/widget-->", true},
|
|
{"comment, no spaces at all", "<!--mcp-name: io.github.acme/widget-->", true},
|
|
{"legacy comment close --!>", "<!-- mcp-name: io.github.acme/widget--!>", true},
|
|
// A genuine longer name with a double hyphen is still NOT a match for the
|
|
// shorter claim (it is not an HTML comment close).
|
|
{"double-hyphen longer name not a match", "mcp-name: io.github.acme/widget--pro\n", false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
if got := containsMCPNameToken(tc.content, name); got != tc.want {
|
|
t.Fatalf("containsMCPNameToken(%q, %q) = %v, want %v", tc.content, name, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMCPNameTokenGluedTrailing covers the diagnostic helper that explains why a
|
|
// visibly-present token failed: it reports the trailing character gluing the
|
|
// token to a longer name, or ("", false) when the token is genuinely absent.
|
|
func TestMCPNameTokenGluedTrailing(t *testing.T) {
|
|
const name = "io.github.acme/widget"
|
|
cases := []struct {
|
|
desc string
|
|
content string
|
|
wantTrailing string
|
|
wantGlued bool
|
|
}{
|
|
{"absent", "no token here", "", false},
|
|
{"glued period", "mcp-name: io.github.acme/widget.", ".", true},
|
|
{"glued hyphen (longer name)", "mcp-name: io.github.acme/widget-pro", "-", true},
|
|
{"glued slash", "mcp-name: io.github.acme/widget/x", "/", true},
|
|
{"at end of content (boundary, not glued)", "mcp-name: io.github.acme/widget", "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
gotTrailing, gotGlued := mcpNameTokenGluedTrailing(tc.content, name)
|
|
if gotGlued != tc.wantGlued || gotTrailing != tc.wantTrailing {
|
|
t.Fatalf("mcpNameTokenGluedTrailing(%q) = (%q, %v), want (%q, %v)",
|
|
tc.content, gotTrailing, gotGlued, tc.wantTrailing, tc.wantGlued)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// FuzzContainsMCPNameToken pins the core safety property of the boundary-anchored
|
|
// matcher: it is strictly stricter than a bare substring check. A true result
|
|
// must imply the literal token is present (strings.Contains). This guards against
|
|
// a future edit to isServerNameChar/isMCPNameBoundary accidentally accepting
|
|
// something the old behavior rejected — i.e. it can only ever flip pass→fail,
|
|
// never fail→pass. Runs the seed corpus under `go test`; exhaustively under
|
|
// `go test -fuzz`.
|
|
func FuzzContainsMCPNameToken(f *testing.F) {
|
|
seeds := []struct{ content, name string }{
|
|
{"mcp-name: io.github.acme/widget", "io.github.acme/widget"},
|
|
{"mcp-name: io.github.acme/widget-pro", "io.github.acme/widget"},
|
|
{"<!-- mcp-name: io.github.acme/widget-->", "io.github.acme/widget"},
|
|
{"prefix mcp-name: a/b then mcp-name: a/b-c", "a/b"},
|
|
{"", ""},
|
|
{"mcp-name: ", ""},
|
|
{"random text with no token", "io.github.x/y"},
|
|
}
|
|
for _, s := range seeds {
|
|
f.Add(s.content, s.name)
|
|
}
|
|
f.Fuzz(func(t *testing.T, content, name string) {
|
|
if containsMCPNameToken(content, name) && !strings.Contains(content, "mcp-name: "+name) {
|
|
t.Fatalf("matcher accepted but literal token absent: content=%q name=%q", content, name)
|
|
}
|
|
})
|
|
}
|