Control: tags -1 - moreinfo
On Tue, 2025-11-11 at 11:33 +0700, Arnaud Rebillout wrote:This being said, I believe it would be more correct to also include upstream unit tests associated with the regression in the debdiff, however if I do that, I'm importing 95% of the changes in 0.6.1, so I'd better ask to upload 0.6.1 instead. I can still do that if you prefer, at your convenience.Could we see what a debdiff for that would look like, please.
New debdiff attached.
Best,
-- Arnaud Rebillout / OffSec / Kali Linux Developer
diff -Nru mirrorbits-0.6/CHANGELOG.md mirrorbits-0.6.1/CHANGELOG.md
--- mirrorbits-0.6/CHANGELOG.md 2025-04-02 23:20:43.000000000 +0700
+++ mirrorbits-0.6.1/CHANGELOG.md 2025-08-16 12:02:24.000000000 +0700
@@ -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 23:20:43.000000000 +0700
+++ mirrorbits-0.6.1/config/config.go 2025-08-16 12:02:24.000000000 +0700
@@ -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 23:59:45.000000000 +0700
+++ mirrorbits-0.6.1/debian/changelog 2025-12-08 09:16:06.000000000 +0700
@@ -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 <arnaudr@debian.org> 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 23:59:45.000000000 +0700
+++ mirrorbits-0.6.1/debian/gbp.conf 2025-12-08 09:16:06.000000000 +0700
@@ -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 23:20:43.000000000 +0700
+++ mirrorbits-0.6.1/http/http.go 2025-08-16 12:02:24.000000000 +0700
@@ -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 08:00:00.000000000 +0800
+++ mirrorbits-0.6.1/http/http_test.go 2025-08-16 12:02:24.000000000 +0700
@@ -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 23:20:43.000000000 +0700
+++ mirrorbits-0.6.1/http/selection.go 2025-08-16 12:02:24.000000000 +0700
@@ -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 23:20:43.000000000 +0700
+++ mirrorbits-0.6.1/network/utils.go 2025-08-16 12:02:24.000000000 +0700
@@ -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 23:20:43.000000000 +0700
+++ mirrorbits-0.6.1/network/utils_test.go 2025-08-16 12:02:24.000000000 +0700
@@ -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 23:20:43.000000000 +0700
+++ mirrorbits-0.6.1/utils/utils.go 2025-08-16 12:02:24.000000000 +0700
@@ -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 23:20:43.000000000 +0700
+++ mirrorbits-0.6.1/utils/utils_test.go 2025-08-16 12:02:24.000000000 +0700
@@ -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) {