Version in base suite: 0.6-1 Base version: mirrorbits_0.6-1 Target version: mirrorbits_0.6.1-1~deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/m/mirrorbits/mirrorbits_0.6-1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/m/mirrorbits/mirrorbits_0.6.1-1~deb13u1.dsc CHANGELOG.md | 7 config/config.go | 20 - debian/changelog | 12 + debian/gbp.conf | 2 http/http.go | 7 http/http_test.go | 550 ++++++++++++++++++++++++++++++++++++++++++++++++++ http/selection.go | 17 + network/utils.go | 24 ++ network/utils_test.go | 46 ++++ utils/utils.go | 25 -- utils/utils_test.go | 47 ---- 11 files changed, 664 insertions(+), 93 deletions(-) diff -Nru mirrorbits-0.6/CHANGELOG.md mirrorbits-0.6.1/CHANGELOG.md --- mirrorbits-0.6/CHANGELOG.md 2025-04-02 16:20:43.000000000 +0000 +++ mirrorbits-0.6.1/CHANGELOG.md 2025-08-16 05:02:24.000000000 +0000 @@ -1,3 +1,10 @@ +## v0.6.1 + +### BUGFIXES + +- Regression: mirrorbits returned "500 Internal Server Error" when the Redis database was not ready, instead of redirecting users to the fallback mirror(s) (#195) +- Fix malformed redirections when the fallback URL(s) (in the configuration file) lacks a trailing slash (c6abff6) + ## v0.6 ### FEATURES diff -Nru mirrorbits-0.6/config/config.go mirrorbits-0.6.1/config/config.go --- mirrorbits-0.6/config/config.go 2025-04-02 16:20:43.000000000 +0000 +++ mirrorbits-0.6.1/config/config.go 2025-08-16 05:02:24.000000000 +0000 @@ -11,6 +11,7 @@ "sync" "github.com/etix/mirrorbits/core" + "github.com/etix/mirrorbits/utils" "github.com/op/go-logging" "gopkg.in/yaml.v3" ) @@ -91,7 +92,7 @@ WeightDistributionRange float32 `yaml:"WeightDistributionRange"` DisableOnMissingFile bool `yaml:"DisableOnMissingFile"` AllowOutdatedFiles []OutdatedFilesConfig `yaml:"AllowOutdatedFiles"` - Fallbacks []fallback `yaml:"Fallbacks"` + Fallbacks []Fallback `yaml:"Fallbacks"` RedisSentinelMasterName string `yaml:"RedisSentinelMasterName"` RedisSentinels []sentinels `yaml:"RedisSentinels"` @@ -100,7 +101,7 @@ RPCPassword string `yaml:"RPCPassword"` } -type fallback struct { +type Fallback struct { URL string `yaml:"URL"` CountryCode string `yaml:"CountryCode"` ContinentCode string `yaml:"ContinentCode"` @@ -162,7 +163,7 @@ if c.WeightDistributionRange <= 0 { return fmt.Errorf("WeightDistributionRange must be > 0") } - if !isInSlice(c.OutputMode, []string{"auto", "json", "redirect"}) { + if !utils.IsInSlice(c.OutputMode, []string{"auto", "json", "redirect"}) { return fmt.Errorf("Config: outputMode can only be set to 'auto', 'json' or 'redirect'") } if c.Repository == "" { @@ -175,6 +176,9 @@ if c.RepositoryScanInterval < 0 { c.RepositoryScanInterval = 0 } + for i := range c.Fallbacks { + c.Fallbacks[i].URL = utils.NormalizeURL(c.Fallbacks[i].URL) + } for _, rule := range c.AllowOutdatedFiles { if len(rule.Prefix) > 0 && rule.Prefix[0] != '/' { return fmt.Errorf("AllowOutdatedFiles.Prefix must start with '/'") @@ -262,13 +266,3 @@ return true } - -//DUPLICATE -func isInSlice(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} diff -Nru mirrorbits-0.6/debian/changelog mirrorbits-0.6.1/debian/changelog --- mirrorbits-0.6/debian/changelog 2025-04-02 16:59:45.000000000 +0000 +++ mirrorbits-0.6.1/debian/changelog 2025-12-08 02:16:06.000000000 +0000 @@ -1,3 +1,15 @@ +mirrorbits (0.6.1-1~deb13u1) trixie; urgency=medium + + * New upstream version [0.6.1] + * Fix "Internal Server Error" regressions. Mirrorbits must redirect users to + the fallback mirror(s) if ever the database is unreachable. This was + broken in version 0.6, and fixed in 0.6.1. + * Normalize URL for fallback mirror(s), as it's done for all the other + mirrors. Fix bogus redirections if ever the fallback URL doesn't end with + a trailing slash. + + -- Arnaud Rebillout Mon, 08 Dec 2025 09:16:06 +0700 + mirrorbits (0.6-1) unstable; urgency=medium * Update watch file for tagged releases diff -Nru mirrorbits-0.6/debian/gbp.conf mirrorbits-0.6.1/debian/gbp.conf --- mirrorbits-0.6/debian/gbp.conf 2025-04-02 16:59:45.000000000 +0000 +++ mirrorbits-0.6.1/debian/gbp.conf 2025-12-08 02:16:06.000000000 +0000 @@ -1,6 +1,6 @@ [DEFAULT] pristine-tar = True -debian-branch = debian/latest +debian-branch = debian/trixie [pq] patch-numbers = False diff -Nru mirrorbits-0.6/http/http.go mirrorbits-0.6.1/http/http.go --- mirrorbits-0.6/http/http.go 2025-04-02 16:20:43.000000000 +0000 +++ mirrorbits-0.6.1/http/http.go 2025-08-16 05:02:24.000000000 +0000 @@ -333,12 +333,11 @@ return } - // Get details about the requested file + // Get details about the requested file. Errors are not fatal, and + // expected when the database is not ready: fallbacks will handle it. fileInfo, err := h.cache.GetFileInfo(urlPath) if err != nil { - log.Errorf("Error while fetching Fileinfo: %s", err.Error()) - http.Error(w, err.Error(), http.StatusInternalServerError) - return + //log.Debugf("Error while fetching Fileinfo: %s", err.Error()) } if checkIfModifiedSince(r, fileInfo.ModTime) == condFalse { diff -Nru mirrorbits-0.6/http/http_test.go mirrorbits-0.6.1/http/http_test.go --- mirrorbits-0.6/http/http_test.go 1970-01-01 00:00:00.000000000 +0000 +++ mirrorbits-0.6.1/http/http_test.go 2025-08-16 05:02:24.000000000 +0000 @@ -0,0 +1,550 @@ +// Copyright (c) 2025 Arnaud Rebillout +// Licensed under the MIT license + +package http + +import ( + "errors" + "io/ioutil" + "net" + "net/http" + "net/http/httptest" + "net/http/httputil" + "os" + "path" + "reflect" + "strings" + "syscall" + "testing" + + . "github.com/etix/mirrorbits/config" + "github.com/etix/mirrorbits/core" + "github.com/etix/mirrorbits/mirrors" + . "github.com/etix/mirrorbits/testing" + "github.com/rafaeljusto/redigomock" +) + +var ( + fallbackURL = "http://fallback.mirror/" + mirrorURL = "http://example.mirror/" + testFile = "/testy.tgz" + testFileSize = "48" + testFileModTime = "2025-06-01 06:00:00.123456789 +0000 UTC" + testFileSha256 = "1235a5b376903794b373d84ed615bb36013e70ed6aebf30b2f4823321d5182ec" + testFileLastModified = "Sun, 01 Jun 2025 06:00:00 GMT" +) + +// Join URL and a path +func urlJoinPath(url, filepath string) string { + return url + strings.TrimLeft(filepath, "/") +} + +// Create an empty file within a directory, fail if it already exists +func makeEmptyFile(dir, filename string) error { + filePath := path.Join(dir, filename) + fileFlags := os.O_CREATE|os.O_EXCL|os.O_WRONLY + f, err := os.OpenFile(filePath, fileFlags, 0644) + if err != nil { + return err + } + return f.Close() +} + +// Make a request +func makeRequest(method, url string, headers map[string]string) *http.Request { + req := httptest.NewRequest(method, url, nil) + for k, v := range headers { + req.Header.Set(k, v) + } + return req +} + +// Make a response, as returned by mirrorbits +func makeResponse(code int, headers map[string]string) *http.Response { + var resp http.Response + + switch code { + case 302: + resp = http.Response{ + Status: "302 Found", + StatusCode: 302, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Cache-Control": {"private, no-cache"}, + "Content-Type": {"text/html; charset=utf-8"}, + "Server": {"Mirrorbits/"+core.VERSION}, + }, + ContentLength: -1, + } + case 304: + resp = http.Response{ + Status: "304 Not Modified", + StatusCode: 304, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Server": {"Mirrorbits/"+core.VERSION}, + }, + ContentLength: -1, + } + case 403: + resp = http.Response{ + Status: "403 Forbidden", + StatusCode: 403, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Content-Type": {"text/plain; charset=utf-8"}, + "Server": {"Mirrorbits/"+core.VERSION}, + "X-Content-Type-Options": {"nosniff"}, + }, + ContentLength: -1, + } + case 404: + resp = http.Response{ + Status: "404 Not Found", + StatusCode: 404, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Content-Type": {"text/plain; charset=utf-8"}, + "Server": {"Mirrorbits/"+core.VERSION}, + "X-Content-Type-Options": {"nosniff"}, + }, + ContentLength: -1, + } + default: + resp = http.Response{} + } + + for k, v := range headers { + resp.Header.Set(k, v) + } + + return &resp +} + +// Do a request and return the response +func doRequest(h *HTTP, method string, url string, headers map[string]string) (*http.Response) { + req := makeRequest(method, url, headers) + recorder := httptest.NewRecorder() + // Note: requestDispatcher calls mirrorHandler + h.requestDispatcher(recorder, req) + return recorder.Result() +} + +// Check if two http.Response are equal, excluding the body +func respEqual(r1 *http.Response, r2 *http.Response) bool { + r1Body, r2Body := r1.Body, r2.Body + r1.Body, r2.Body = nil, nil + res := reflect.DeepEqual(r1, r2) + r1.Body, r2.Body = r1Body, r2Body + return res +} + +// Dump a response, excluding the body, no error checking +func dump(resp *http.Response) string { + dump, _ := httputil.DumpResponse(resp, false) + return string(dump) +} + +// Return the following error: +// dial tcp 127.0.0.1:6379: connect: connection refused +func connectionRefusedError() error { + ip := net.ParseIP("127.0.0.1") + tcpAddr := &net.TCPAddr{IP: ip, Port: 6379} + var addr net.Addr = tcpAddr + syscallErr := os.NewSyscallError("connect", syscall.ECONNREFUSED) + return &net.OpError{Op: "dial", Net: "tcp", Addr: addr, Err: syscallErr} +} + +// Return the following error: +// LOADING Redis is loading the dataset in memory +func redisIsLoadingError() error { + return errors.New("LOADING Redis is loading the dataset in memory") +} + +// A pair consisting of a redis command, and its expected result +type mockedCmd struct { + Cmd []string + Res interface{} +} + +// Register a list of mocked redis commands +func mockCommands(mock *redigomock.Conn, commands []mockedCmd) { + for _, item := range commands { + // Craft arguments for mock.Command, then mock + args := []interface{}{} + for _, arg := range item.Cmd[1:] { + args = append(args, arg) + } + cmd := mock.Command(item.Cmd[0], args...) + + // Add an expectation + switch item.Res.(type) { + case error: + cmd.ExpectError(item.Res.(error)) + case []string: + cmd.ExpectStringSlice(item.Res.([]string)...) + case map[string]string: + cmd.ExpectMap(item.Res.(map[string]string)) + default: + // unknown type? that's a programming error + + } + } +} + +// Wrapper around redigomock.ExpectationsWereMet() to return a slice of errors +func getMockErrors(mock *redigomock.Conn) (result []error) { + err := mock.ExpectationsWereMet() + if err != nil { + lines := strings.Split(err.Error(), "\n") + for _, line := range lines { + line := strings.TrimSpace(line) + if line == "" { + continue + } + // A PING command might or might not have been sent during + // the tests (due to `ConnectPubsub()` I believe). Since we + // don't mock it, we must filter it out from the errors. + if strings.HasPrefix(line, "command PING ") && + strings.HasSuffix(line, " not registered in redigomock library") { + continue + } + result = append(result, errors.New(line)) + } + } + return +} + +// Context for a test +type testContext struct { + TestDir string + RepoDir string + MockedConn *redigomock.Conn + MirrorCache *mirrors.Cache + Server *HTTP +} + +// Prepare a test, return the context +func prepareTest(filenames []string) (testContext, error) { + // Create a temporary directory for test data + testDir, err := ioutil.TempDir("", "mirrorbits-tests") + if err != nil { + return testContext{}, err + } + + defer func() { + if err != nil { + os.RemoveAll(testDir) + } + }() + + // Create the repo directory, along with dummy files + repoDir := testDir + "/repo" + err = os.Mkdir(repoDir, 0755) + if err != nil { + return testContext{}, err + } + + for _, f := range filenames { + err = makeEmptyFile(repoDir, f) + if err != nil { + return testContext{}, err + } + } + + // Create the templates directory, along with dummy templates + templatesDir := testDir + "/templates" + err = os.Mkdir(templatesDir, 0755) + if err != nil { + return testContext{}, err + } + + templates := []string{"base.html", "mirrorlist.html", "mirrorstats.html"} + for _, f := range templates { + err = makeEmptyFile(templatesDir, f) + if err != nil { + return testContext{}, err + } + } + + // Set mirrorbits configuration + SetConfiguration(&Configuration{ + Repository: repoDir, + Templates: templatesDir, + OutputMode: "redirect", + MaxLinkHeaders: 5, + Fallbacks: []Fallback{ + {URL: fallbackURL}, + }, + }) + + // Reset the default server before each test. Must be done before + // creating a HTTPServer instance, otherwise we run into: + // + // panic: http: multiple registrations for / + // + // Cf. https://stackoverflow.com/a/40790728/ + http.DefaultServeMux = new(http.ServeMux) + + // Setup HTTP server + mock, conn := PrepareRedisTest() + conn.ConnectPubsub() + cache := mirrors.NewCache(conn) + h := HTTPServer(conn, cache) + + // Ready for testing! + return testContext { + TestDir: testDir, + RepoDir: repoDir, + MockedConn: mock, + MirrorCache: cache, + Server: h, + }, nil +} + +// Cleanup after a test is done +func cleanupTest(ctx testContext) { + if ctx.TestDir != "" { + os.RemoveAll(ctx.TestDir) + } +} + +// Test 4xx return codes from MirrorHandler. +// +// Those HTTP codes are triggered when the file requested doesn't even exist in +// the local repo. Mirrorbits doesn't query the database in those cases, so +// there's no need to mock redis commands. +func TestMirrorHandler4xx(t *testing.T) { + // Prepare + ctx, err := prepareTest([]string{}) + if err != nil { + t.Fatal(err) + } + defer cleanupTest(ctx) + + noHeader := map[string]string{} + + // Request a file that doesn't exist on the local repo + // -> return 404 "Not Found" + resp := doRequest(ctx.Server, "GET", "/foobar", noHeader) + want := makeResponse(404, noHeader) + if !respEqual(want, resp) { + t.Fatalf("Expected: %v, got: %v", want, resp) + } + + // Request a file outside of the local repo + // -> return 403 "Forbidden" + resp = doRequest(ctx.Server, "GET", "/../foobar", noHeader) + want = makeResponse(403, noHeader) + if !respEqual(want, resp) { + t.Fatalf("Expected: %v, got: %v", want, resp) + } + + // Request a file while the repo directory doesn't even exist + // -> return 404 "Not Found" + if err = os.Remove(ctx.RepoDir); err != nil { + t.Fatal(err) + } + resp = doRequest(ctx.Server, "GET", "/foobar", noHeader) + want = makeResponse(404, noHeader) + if !respEqual(want, resp) { + t.Fatalf("Expected: %v, got: %v", want, resp) + } +} + +var mockedCmds302Fallback = [][]mockedCmd{ + // Database is unreachable (redis error "connection refused") + { + { + Cmd: []string{"HMGET", "FILE_"+testFile, "size", "modTime", "sha1", "sha256", "md5"}, + Res: connectionRefusedError(), + }, + }, + // Database is loading + { + { + Cmd: []string{"HMGET", "FILE_"+testFile, "size", "modTime", "sha1", "sha256", "md5"}, + Res: redisIsLoadingError(), + }, + }, + // Database is reachable. File exists in the local repo, but is not + // found in the database (in real-life, it means that the local repo + // was updated with new files, but mirrorbits didn't rescan it yet) + { + { + Cmd: []string{"HMGET", "FILE_"+testFile, "size", "modTime", "sha1", "sha256", "md5"}, + Res: []string{"", "", "", "", "", ""}, + }, + }, + // Database is reachable, file exists in the local repo, and is also + // present in the database, however no mirror have this file yet + { + { + Cmd: []string{"HMGET", "FILE_"+testFile, "size", "modTime", "sha1", "sha256", "md5"}, + Res: []string{testFileSize, testFileModTime, "", testFileSha256, ""}, + }, + { + Cmd: []string{"SMEMBERS", "FILEMIRRORS_"+testFile}, + Res: []string{}, + }, + }, +} + +var mockedCmds302Mirror = [][]mockedCmd{ + // Database is reachable, file exists in the local repo, is also + // present in the database, and is found on a mirror. + // + // Note: At startup, mirrorbits says "Can't load the GeoIP databases, + // all requests will be served by the fallback mirrors". Well it + // doesn't seem to be true, as this test case shows. + { + { + Cmd: []string{"HMGET", "FILE_"+testFile, "size", "modTime", "sha1", "sha256", "md5"}, + Res: []string{testFileSize, testFileModTime, "", testFileSha256, ""}, + }, + { + Cmd: []string{"SMEMBERS", "FILEMIRRORS_"+testFile}, + Res: []string{"42"}, + }, + { + Cmd: []string{"HGETALL", "MIRROR_42"}, + Res: map[string]string{ + "ID": "42", + "http": mirrorURL, + "enabled": "true", + "httpUp": "true", + }, + }, + { + Cmd: []string{"HMGET", "FILEINFO_42_"+testFile, "size", "modTime", "sha1", "sha256", "md5"}, + Res: []string{testFileSize, testFileModTime, "", "", ""}, + }, + }, +} + +var mockedCmds304 = [][]mockedCmd{ + // File exists in the database, and is older than the If-Modified-Since + // request header, so mirrorbits returns early and doesn't even check + // if mirrors have the file. + { + { + Cmd: []string{"HMGET", "FILE_"+testFile, "size", "modTime", "sha1", "sha256", "md5"}, + Res: []string{testFileSize, testFileModTime, "", testFileSha256, ""}, + }, + }, +} + +// Test 3xx status codes. +// +// Mocking redis can be tricky. If we forget to mock a command, we'll get an +// error of the type: +// +// command [...] not registered in redigomock library +// +// However a redis error makes mirrorbits bail out early from mirror selection, +// and in turns it triggers a fallback redirection. So from the outside, all we +// know is that yes, mirrorbits returned a fallback redirect, and maybe that's +// what we expect, so the test pass, but in fact it passed _because_ we forgot +// to mock a redis command! +// +// That's why it's not enough to just check if mocked commands were called, we +// also need to make sure that redigomock didn't return any error that were +// unexpected. +func TestMirrorHandler3xx(t *testing.T) { + // Prepare + ctx, err := prepareTest([]string{testFile}) + if err != nil { + t.Fatal(err) + } + defer cleanupTest(ctx) + + // Define tests + tests := map[string]struct { + MockedCommands [][]mockedCmd + RequestHeaders map[string]string + Response *http.Response + } { + // Test various scenarios that lead to a fallback redirection + "fallback_redirect": { + MockedCommands: mockedCmds302Fallback, + Response: makeResponse(302, map[string]string{ + "Location": urlJoinPath(fallbackURL, testFile), + }), + }, + // Same as above, but this time passing a If-Modified-Since + // header, set to an old date, so no consequence on the result + "fallback_redirect_old_if_modified_since": { + MockedCommands: mockedCmds302Fallback, + RequestHeaders: map[string]string{ + "If-Modified-Since": "Tue, 01 Jun 1999 00:00:00 GMT", + }, + Response: makeResponse(302, map[string]string{ + "Location": urlJoinPath(fallbackURL, testFile), + }), + }, + // Test mirror redirection + "mirror_redirect": { + MockedCommands: mockedCmds302Mirror, + Response: makeResponse(302, map[string]string{ + "Location": urlJoinPath(mirrorURL, testFile), + }), + }, + // Same as above, but with a old If-Modified-Since + "mirror_redirect_old_if_modified_since": { + MockedCommands: mockedCmds302Mirror, + RequestHeaders: map[string]string{ + "If-Modified-Since": "Tue, 01 Jun 1999 00:00:00 GMT", + }, + Response: makeResponse(302, map[string]string{ + "Location": urlJoinPath(mirrorURL, testFile), + }), + }, + // Test "304 Not Modified" by setting a If-Modified-Since header + // that is newer that the test file modification time + "not_modified": { + MockedCommands: mockedCmds304, + RequestHeaders: map[string]string{ + "If-Modified-Since": "Wed, 04 Jun 2025 02:12:35 GMT", + }, + Response: makeResponse(304, map[string]string{ + "Last-Modified": testFileLastModified, + }), + }, + } + + // Run tests + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + for i, commands := range tt.MockedCommands { + // Register mocked commands + mockCommands(ctx.MockedConn, commands) + + // Request the file + resp := doRequest(ctx.Server, "GET", testFile, tt.RequestHeaders) + + // Check that mocking went fine + for _, err := range getMockErrors(ctx.MockedConn) { + t.Errorf("#%d: %s", i, err) + } + + // Check that response is as expected + if !respEqual(tt.Response, resp) { + //t.Errorf("#%d: Expected: %v, got: %v", i, tt.Response, resp) + t.Errorf("#%d: Expected:\n%sGot:\n%s", i, dump(tt.Response), dump(resp)) + } + + // Cleanup + ctx.MockedConn.Clear() + ctx.MirrorCache.Clear() + } + }) + } +} diff -Nru mirrorbits-0.6/http/selection.go mirrorbits-0.6.1/http/selection.go --- mirrorbits-0.6/http/selection.go 2025-04-02 16:20:43.000000000 +0000 +++ mirrorbits-0.6.1/http/selection.go 2025-08-16 05:02:24.000000000 +0000 @@ -4,6 +4,7 @@ package http import ( + "errors" "fmt" "math" "math/rand" @@ -18,6 +19,10 @@ "github.com/etix/mirrorbits/utils" ) +var ( + ErrInvalidFileInfo = errors.New("Invalid file info (modtime is zero)") +) + type mirrorSelection interface { // Selection must return an ordered list of selected mirror, // a list of rejected mirrors and and an error code. @@ -29,6 +34,12 @@ // Selection returns an ordered list of selected mirror, a list of rejected mirrors and and an error code func (h DefaultEngine) Selection(ctx *Context, cache *mirrors.Cache, fileInfo *filesystem.FileInfo, clientInfo network.GeoIPRecord) (mlist mirrors.Mirrors, excluded mirrors.Mirrors, err error) { + // Bail out early if we don't have valid file details + if fileInfo.ModTime.IsZero() { + err = ErrInvalidFileInfo + return + } + // Prepare and return the list of all potential mirrors mlist, err = cache.GetMirrors(fileInfo.Path, clientInfo) if err != nil { @@ -76,13 +87,13 @@ if m.Distance <= closestMirror*GetConfig().WeightDistributionRange { score := (float32(baseScore) - m.Distance) - if !utils.IsPrimaryCountry(clientInfo, m.CountryFields) { + if !network.IsPrimaryCountry(clientInfo, m.CountryFields) { score /= 2 } m.ComputedScore += int(score) - } else if utils.IsPrimaryCountry(clientInfo, m.CountryFields) { + } else if network.IsPrimaryCountry(clientInfo, m.CountryFields) { m.ComputedScore += int(float32(baseScore) - (m.Distance * 5)) - } else if utils.IsAdditionalCountry(clientInfo, m.CountryFields) { + } else if network.IsAdditionalCountry(clientInfo, m.CountryFields) { m.ComputedScore += int(float32(baseScore) - closestMirror) } diff -Nru mirrorbits-0.6/network/utils.go mirrorbits-0.6.1/network/utils.go --- mirrorbits-0.6/network/utils.go 2025-04-02 16:20:43.000000000 +0000 +++ mirrorbits-0.6.1/network/utils.go 2025-08-16 05:02:24.000000000 +0000 @@ -42,3 +42,27 @@ } return "" } + +// IsPrimaryCountry returns true if the clientInfo country is the primary country +func IsPrimaryCountry(clientInfo GeoIPRecord, list []string) bool { + if !clientInfo.IsValid() { + return false + } + if len(list) > 0 && list[0] == clientInfo.CountryCode { + return true + } + return false +} + +// IsAdditionalCountry returns true if the clientInfo country is in list +func IsAdditionalCountry(clientInfo GeoIPRecord, list []string) bool { + if !clientInfo.IsValid() { + return false + } + for i, b := range list { + if i > 0 && b == clientInfo.CountryCode { + return true + } + } + return false +} diff -Nru mirrorbits-0.6/network/utils_test.go mirrorbits-0.6.1/network/utils_test.go --- mirrorbits-0.6/network/utils_test.go 2025-04-02 16:20:43.000000000 +0000 +++ mirrorbits-0.6.1/network/utils_test.go 2025-08-16 05:02:24.000000000 +0000 @@ -35,3 +35,49 @@ t.Fatalf("Expected '192.168.0.1', got %s", r) } } + +func TestIsPrimaryCountry(t *testing.T) { + var b bool + list := []string{"FR", "DE", "GR"} + + clientInfo := GeoIPRecord{ + CountryCode: "FR", + } + + b = IsPrimaryCountry(clientInfo, list) + if !b { + t.Fatal("Expected true, got false") + } + + clientInfo = GeoIPRecord{ + CountryCode: "GR", + } + + b = IsPrimaryCountry(clientInfo, list) + if b { + t.Fatal("Expected false, got true") + } +} + +func TestIsAdditionalCountry(t *testing.T) { + var b bool + list := []string{"FR", "DE", "GR"} + + clientInfo := GeoIPRecord{ + CountryCode: "FR", + } + + b = IsAdditionalCountry(clientInfo, list) + if b { + t.Fatal("Expected false, got true") + } + + clientInfo = GeoIPRecord{ + CountryCode: "GR", + } + + b = IsAdditionalCountry(clientInfo, list) + if !b { + t.Fatal("Expected true, got false") + } +} diff -Nru mirrorbits-0.6/utils/utils.go mirrorbits-0.6.1/utils/utils.go --- mirrorbits-0.6/utils/utils.go 2025-04-02 16:20:43.000000000 +0000 +++ mirrorbits-0.6.1/utils/utils.go 2025-08-16 05:02:24.000000000 +0000 @@ -11,7 +11,6 @@ "time" "github.com/etix/mirrorbits/core" - "github.com/etix/mirrorbits/network" ) const ( @@ -92,30 +91,6 @@ } return false } - -// IsAdditionalCountry returns true if the clientInfo country is in list -func IsAdditionalCountry(clientInfo network.GeoIPRecord, list []string) bool { - if !clientInfo.IsValid() { - return false - } - for i, b := range list { - if i > 0 && b == clientInfo.CountryCode { - return true - } - } - return false -} - -// IsPrimaryCountry returns true if the clientInfo country is the primary country -func IsPrimaryCountry(clientInfo network.GeoIPRecord, list []string) bool { - if !clientInfo.IsValid() { - return false - } - if len(list) > 0 && list[0] == clientInfo.CountryCode { - return true - } - return false -} // IsStopped returns true if a stop has been requested func IsStopped(stop <-chan struct{}) bool { diff -Nru mirrorbits-0.6/utils/utils_test.go mirrorbits-0.6.1/utils/utils_test.go --- mirrorbits-0.6/utils/utils_test.go 2025-04-02 16:20:43.000000000 +0000 +++ mirrorbits-0.6.1/utils/utils_test.go 2025-08-16 05:02:24.000000000 +0000 @@ -8,7 +8,6 @@ "time" "github.com/etix/mirrorbits/core" - "github.com/etix/mirrorbits/network" ) func TestHasAnyPrefix(t *testing.T) { @@ -101,52 +100,6 @@ if b { t.Fatal("Expected false, got true") } -} - -func TestIsAdditionalCountry(t *testing.T) { - var b bool - list := []string{"FR", "DE", "GR"} - - clientInfo := network.GeoIPRecord{ - CountryCode: "FR", - } - - b = IsAdditionalCountry(clientInfo, list) - if b { - t.Fatal("Expected false, got true") - } - - clientInfo = network.GeoIPRecord{ - CountryCode: "GR", - } - - b = IsAdditionalCountry(clientInfo, list) - if !b { - t.Fatal("Expected true, got false") - } -} - -func TestIsPrimaryCountry(t *testing.T) { - var b bool - list := []string{"FR", "DE", "GR"} - - clientInfo := network.GeoIPRecord{ - CountryCode: "FR", - } - - b = IsPrimaryCountry(clientInfo, list) - if !b { - t.Fatal("Expected true, got false") - } - - clientInfo = network.GeoIPRecord{ - CountryCode: "GR", - } - - b = IsPrimaryCountry(clientInfo, list) - if b { - t.Fatal("Expected false, got true") - } } func TestIsStopped(t *testing.T) {