[Date Prev][Date Next] [Thread Prev][Thread Next] [Date Index] [Thread Index]

Bug#1120493: trixie-pu: package mirrorbits/0.6-1+deb13u1



Control: tags -1 - moreinfo

On 06/12/2025 23:30, Adam D. Barratt wrote:
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) {

Reply to: