2026-10-12 09:42:38 +02:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/config"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/database"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/handler"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/storage"
|
|
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// countingTransport records every upstream request and fails it, so a test
|
|
|
|
|
// can tell whether a handler reached out at all.
|
|
|
|
|
type countingTransport struct{ hits atomic.Int32 }
|
|
|
|
|
|
|
|
|
|
func (c *countingTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
|
|
|
|
c.hits.Add(1)
|
|
|
|
|
return nil, errors.New("upstream contacted")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestProtocolHandlersRejectQueryInPath sends a path with an encoded '?' or
|
|
|
|
|
// '#' to every mounted ecosystem. Without the guard, most of these fetch the
|
|
|
|
|
// file before the '?' and cache it under the name after it.
|
|
|
|
|
func TestProtocolHandlersRejectQueryInPath(t *testing.T) {
|
|
|
|
|
db, err := database.Create(t.TempDir() + "/test.db")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("creating database: %v", err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
|
store, err := storage.OpenBucket(context.Background(), "file://"+t.TempDir())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("opening storage: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transport := &countingTransport{}
|
|
|
|
|
client := &http.Client{Transport: transport}
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(client), fetch.WithMaxRetries(0))
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
proxy := handler.NewProxy(db, store, fetcher, fetch.NewResolver(), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
|
|
|
proxy.HTTPClient = client
|
|
|
|
|
|
|
|
|
|
cfg := config.Default()
|
|
|
|
|
cfg.BaseURL = "http://proxy.example"
|
|
|
|
|
cfg.Upstream.Generic = map[string]string{"github": "https://github.com"}
|
|
|
|
|
s := &Server{cfg: cfg}
|
|
|
|
|
r := chi.NewRouter()
|
|
|
|
|
s.mountProtocolHandlers(r, proxy)
|
|
|
|
|
|
|
|
|
|
for _, target := range []string{
|
|
|
|
|
"/apk/alpine/v3.22/main/x86_64/busybox-1.37.0-r12.apk%3F/foo-9.9-r0.apk",
|
|
|
|
|
"/apk/alpine/v3.22/main/x86_64/busybox-1.37.0-r12.apk%23/foo-9.9-r0.apk",
|
|
|
|
|
"/rpm/Packages/n/nginx-1.24.0-1.fc39.x86_64.rpm%3F/foo-9.9-1.fc39.x86_64.rpm",
|
|
|
|
|
"/debian/pool/main/c/curl/curl_8.5.0-2_amd64.deb%3F/foo_9.9-1_amd64.deb",
|
|
|
|
|
"/generic/github/owner/repo/releases/download/v1/tool.tar.gz%3F/other/repo/releases/download/v2/tool.tar.gz",
|
|
|
|
|
"/pypi/packages/ab/cd/ef/requests-2.31.0.tar.gz%3F/ab/cd/ef/foo-9.9.tar.gz",
|
|
|
|
|
"/maven/com/google/guava/guava/32.1.3-jre/guava-32.1.3-jre.jar%3F/org/evil/foo/9.9/foo-9.9.jar",
|
|
|
|
|
"/conda/conda-forge/linux-64/numpy-1.26.0-py312_0.conda%3F%2Ffoo-9.9-py_0.conda",
|
|
|
|
|
"/cran/src/contrib/ggplot2_3.4.0.tar.gz%3F%2Ffoo_9.9.tar.gz",
|
|
|
|
|
"/gem/gems/rails-7.0.0.gem%3F%2Ffoo-9.9.gem",
|
|
|
|
|
"/hex/tarballs/phoenix-1.7.10.tar%3F%2Ffoo-9.9.tar",
|
|
|
|
|
"/pub/packages/http/versions/0.13.0.tar.gz%3F1.2.0.tar.gz",
|
|
|
|
|
"/go/golang.org/x/text/@v/v0.3.0.zip%3F/@v/v0.3.8.zip",
|
|
|
|
|
"/nuget/v3-flatcontainer/newtonsoft.json/13.0.1/newtonsoft.json.13.0.1.nupkg%3F%2Ffoo.9.9.nupkg",
|
|
|
|
|
"/conan/v2/files/zlib/1.3/_/_/rev/recipe/conanfile.py%3F%2Fx",
|
|
|
|
|
"/composer/p2/monolog/monolog.json%3Fx",
|
|
|
|
|
"/npm/lodash%3F/-/lodash-4.17.21.tgz",
|
|
|
|
|
"/cargo/crates/serde%3F/1.0.0/download",
|
|
|
|
|
"/julia/registry/23338594-aafe-5451-b93e-139f81909106/abc%3F",
|
|
|
|
|
"/swift/apple/swift-nio/2.0.0%3F.zip",
|
|
|
|
|
"/homebrew/formula%3F.json",
|
|
|
|
|
"/helm/charts/nginx-1.0.0.tgz%3F",
|
|
|
|
|
"/v2/library/alpine/manifests/latest%3F",
|
|
|
|
|
"/gradle/cache/abc%3F",
|
|
|
|
|
} {
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("%s: status = %d, want 400", target, w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if n := transport.hits.Load(); n != 0 {
|
|
|
|
|
t.Errorf("upstream was contacted %d times", n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestComposerFilesAllowHashInVersion keeps Composer archive downloads of git
|
2026-10-23 09:47:07 +02:00
|
|
|
// branches with '#' or ';' in their name working: the version is escaped in
|
|
|
|
|
// the dist URL and the archive is resolved from metadata, not from the
|
|
|
|
|
// request path.
|
2026-10-12 09:42:38 +02:00
|
|
|
func TestComposerFilesAllowHashInVersion(t *testing.T) {
|
2026-10-23 09:47:07 +02:00
|
|
|
for _, target := range []string{
|
|
|
|
|
"/composer/files/acme/lib/dev-fix%2F%2342/abc123.zip",
|
|
|
|
|
"/composer/files/acme/lib/dev-fix%3B42/abc123.zip",
|
|
|
|
|
} {
|
|
|
|
|
var reached atomic.Bool
|
|
|
|
|
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached.Store(true) })
|
2026-10-12 09:42:38 +02:00
|
|
|
|
2026-10-23 09:47:07 +02:00
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
rejectQueryInPath(next).ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
|
|
|
|
if w.Code == http.StatusBadRequest || !reached.Load() {
|
|
|
|
|
t.Errorf("%s: status = %d, reached = %v; want the request passed on", target, w.Code, reached.Load())
|
|
|
|
|
}
|
2026-10-12 09:42:38 +02:00
|
|
|
}
|
|
|
|
|
}
|