fix(deb): normalize apt flat-repo ./ dist prefix in local index
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

Real apt appends the flat-repo dist "./" verbatim, requesting <repo>/./Packages
(and ./Release, ./InRelease). ServeLocalIndex matched the literal filename and
404d, breaking the documented `deb ... <repo>/ ./` one-liner; curl/browsers
pre-normalize /./ which masked it. Collapse the dot-segment via a small
normalizeIndexPath helper before matching, a no-op for pool/*.deb lookups.
This commit is contained in:
unkin-agent
2026-08-11 22:39:30 +10:00
parent a35f260b74
commit d7e6ec43ff
2 changed files with 58 additions and 1 deletions
+11 -1
View File
@@ -14,6 +14,7 @@ import (
"io"
"log/slog"
"net/http"
"path"
"regexp"
"strconv"
"strings"
@@ -305,7 +306,16 @@ func parseControlFields(control string) map[string]string {
return fields
}
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
// normalizeIndexPath collapses apt's verbatim dist prefix from a flat-repo
// request. For `deb ... <repo>/ ./`, apt appends the "./" dist literally and asks
// for "./Packages" (and "./Release", "./InRelease"); dot-segments must be
// collapsed so the index matcher sees "Packages". A no-op for pool/*.deb paths.
func normalizeIndexPath(p string) string {
return strings.TrimPrefix(path.Clean("/"+p), "/")
}
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, reqPath string) bool {
path := normalizeIndexPath(reqPath)
switch path {
case "Packages", "Packages.gz", "Release":
default:
+47
View File
@@ -295,6 +295,53 @@ func TestDebServeLocalIndex(t *testing.T) {
}
}
// Real apt appends the flat-repo dist "./" verbatim, so it requests "./Packages"
// / "./Release" (curl pre-normalizes /./ which masks this). The handler must
// collapse the dot-segment and return the same bytes as the un-prefixed request.
func TestDebServeLocalIndexAptDotSegment(t *testing.T) {
p := &Provider{}
reader := fakeDebReader{metas: []provider.DebMetadata{
{Name: "aaa", Version: "1.0", Architecture: "amd64", FilePath: "pool/aaa_1.0_amd64.deb",
Control: "Package: aaa\nVersion: 1.0\nArchitecture: amd64", Size: 100, MD5: "md5aaa", SHA256: "sha256aaa"},
}}
serve := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
if !p.ServeLocalIndex(w, r, reader, "myrepo", path) {
t.Fatalf("ServeLocalIndex returned false for %q", path)
}
return w
}
// Packages is deterministic: require exact byte identity.
if plain, dotted := serve("Packages"), serve("./Packages"); plain.Code != 200 || dotted.Code != 200 {
t.Fatalf("Packages: plain=%d dotted=%d, want 200/200", plain.Code, dotted.Code)
} else if !bytes.Equal(plain.Body.Bytes(), dotted.Body.Bytes()) {
t.Error("./Packages body differs from Packages body")
}
// Release carries a Date: header stamped from time.Now(); compare the rest.
plain, dotted := serve("Release"), serve("./Release")
if plain.Code != 200 || dotted.Code != 200 {
t.Fatalf("Release: plain=%d dotted=%d, want 200/200", plain.Code, dotted.Code)
}
if stripDate(plain.Body.String()) != stripDate(dotted.Body.String()) {
t.Error("./Release body differs from Release body (ignoring Date)")
}
}
func stripDate(s string) string {
var out []string
for _, line := range strings.Split(s, "\n") {
if strings.HasPrefix(line, "Date:") {
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
func TestDebServeMetadataError(t *testing.T) {
p := &Provider{}
for _, path := range []string{"Packages", "Packages.gz", "Release"} {