diff --git a/rpc/rpc.go b/rpc/rpc.go index 8955a0ad..d8dba9d3 100644 --- a/rpc/rpc.go +++ b/rpc/rpc.go @@ -129,18 +129,40 @@ func (c *CLI) MatchMirror(ctx context.Context, in *MatchRequest) (*MatchReply, e return nil, fmt.Errorf("can't fetch the list of mirrors: %w", err) } - reply := &MatchReply{} + reply := &MatchReply{ + Mirrors: matchMirrorsByPattern(mirrors, in.Pattern), + } + + return reply, nil +} + +// matchMirrorsByPattern returns a list of mirrors: +// - if the pattern matches a mirror's name exactly, only that mirror is returned +// - otherwise, all mirrors containing the pattern as a substring are returned +// - all matches are case-insensitive +// This allows a mirror whose name is a substring of other mirror names +// (e.g. "fcix.net" vs. "mirror.fcix.net") to still be matched unambiguously. +func matchMirrorsByPattern(mirrors map[int]string, pattern string) []*MirrorID { + lowerPattern := strings.ToLower(pattern) + var matches []*MirrorID for id, name := range mirrors { - if strings.Contains(strings.ToLower(name), strings.ToLower(in.Pattern)) { - reply.Mirrors = append(reply.Mirrors, &MirrorID{ + lowerName := strings.ToLower(name) + if lowerName == lowerPattern { + return []*MirrorID{{ + ID: int32(id), + Name: name, + }} + } + if strings.Contains(lowerName, lowerPattern) { + matches = append(matches, &MirrorID{ ID: int32(id), Name: name, }) } } - return reply, nil + return matches } func (c *CLI) ChangeStatus(ctx context.Context, in *ChangeStatusRequest) (*empty.Empty, error) { diff --git a/rpc/rpc_test.go b/rpc/rpc_test.go new file mode 100644 index 00000000..94984ce5 --- /dev/null +++ b/rpc/rpc_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Amit Mishra +// Licensed under the MIT license + +package rpc + +import ( + "sort" + "testing" +) + +func names(mirrors []*MirrorID) []string { + var out []string + for _, m := range mirrors { + out = append(out, m.Name) + } + sort.Strings(out) + return out +} + +func TestMatchMirrorsByPattern(t *testing.T) { + // Regression test for https://github.com/videolabs/mirrorbits/issues/134 + tests := []struct { + name string + mirrors map[int]string + pattern string + want []string + }{ + { + name: "exact match takes priority over substring matches", + mirrors: map[int]string{ + 1: "fcix.net", + 2: "mirror.fcix.net", + 3: "paducahix.mm.fcix.net", + 4: "forksystems.mm.fcix.net", + }, + pattern: "fcix.net", + want: []string{"fcix.net"}, + }, + { + name: "exact match is case-insensitive", + mirrors: map[int]string{ + 1: "FCIX.net", + 2: "mirror.fcix.net", + }, + pattern: "fcix.net", + want: []string{"FCIX.net"}, + }, + { + name: "multiple substring matches returned when no exact match", + mirrors: map[int]string{ + 1: "mirror.fcix.net", + 2: "paducahix.mm.fcix.net", + }, + pattern: "fcix.net", + want: []string{"mirror.fcix.net", "paducahix.mm.fcix.net"}, + }, + { + name: "no match returns empty", + mirrors: map[int]string{ + 1: "alpha", + 2: "beta", + }, + pattern: "gamma", + want: nil, + }, + { + name: "single substring match", + mirrors: map[int]string{ + 1: "mirror.example.com", + 2: "other.example.org", + }, + pattern: "example.com", + want: []string{"mirror.example.com"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := names(matchMirrorsByPattern(tc.mirrors, tc.pattern)) + + if len(got) != len(tc.want) { + t.Fatalf("matchMirrorsByPattern(%q) = %v, want %v", tc.pattern, got, tc.want) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Fatalf("matchMirrorsByPattern(%q) = %v, want %v", tc.pattern, got, tc.want) + } + } + }) + } +}