Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-08-23 12:24:57 -04:00
pkg-proxy/internal/server/resolve_test.go
wickedOne 849500de1e
fix: decode PURL percent-encoding in versions and package paths (#244)
* fix: decode PURL percent-encoding in versions and package paths

* review fix
2026-08-14 10:38:08 +01:00

241 lines
8.2 KiB
Go

package server
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/git-pkgs/proxy/internal/database"
"github.com/go-chi/chi/v5"
)
func newTestDB(t *testing.T) (*database.DB, func()) {
t.Helper()
dir, err := os.MkdirTemp("", "resolve-test-*")
if err != nil {
t.Fatal(err)
}
db, err := database.Create(filepath.Join(dir, "test.db"))
if err != nil {
_ = os.RemoveAll(dir)
t.Fatal(err)
}
return db, func() { _ = db.Close(); _ = os.RemoveAll(dir) }
}
func seedPackage(t *testing.T, db *database.DB, ecosystem, name, purl string) {
t.Helper()
if err := db.UpsertPackage(&database.Package{
PURL: purl, Ecosystem: ecosystem, Name: name,
}); err != nil {
t.Fatalf("failed to upsert package %s: %v", name, err)
}
}
func TestResolvePackageName(t *testing.T) {
db, cleanup := newTestDB(t)
defer cleanup()
seedPackage(t, db, "npm", "lodash", "pkg:npm/lodash")
seedPackage(t, db, "composer", "monolog/monolog", "pkg:composer/monolog/monolog")
seedPackage(t, db, "composer", "symfony/console", "pkg:composer/symfony/console")
tests := []struct {
name string
ecosystem string
segments []string
wantName string
wantRest []string
}{
{
name: "simple package", ecosystem: "npm",
segments: []string{"lodash"}, wantName: "lodash", wantRest: nil,
},
{
name: "simple package with version", ecosystem: "npm",
segments: []string{"lodash", "4.17.21"}, wantName: "lodash", wantRest: []string{"4.17.21"},
},
{
name: "namespaced package", ecosystem: "composer",
segments: []string{"monolog", "monolog"}, wantName: "monolog/monolog", wantRest: nil,
},
{
name: "namespaced package with version", ecosystem: "composer",
segments: []string{"symfony", "console", "6.0.0"}, wantName: "symfony/console", wantRest: []string{"6.0.0"},
},
{
name: "namespaced with version and action", ecosystem: "composer",
segments: []string{"symfony", "console", "6.0.0", "browse"},
wantName: "symfony/console", wantRest: []string{"6.0.0", "browse"},
},
{
name: "not found", ecosystem: "npm",
segments: []string{"nonexistent"}, wantName: "", wantRest: []string{"nonexistent"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, rest := resolvePackageName(db, tt.ecosystem, tt.segments)
if name != tt.wantName {
t.Errorf("name = %q, want %q", name, tt.wantName)
}
if len(rest) != len(tt.wantRest) {
t.Errorf("rest = %v, want %v", rest, tt.wantRest)
} else {
for i := range rest {
if rest[i] != tt.wantRest[i] {
t.Errorf("rest[%d] = %q, want %q", i, rest[i], tt.wantRest[i])
}
}
}
})
}
}
func TestSplitWildcardPath(t *testing.T) {
tests := []struct {
input string
encoded bool
want []string
}{
{"lodash", false, []string{"lodash"}},
{"lodash/4.17.21", false, []string{"lodash", "4.17.21"}},
{"monolog/monolog", false, []string{"monolog", "monolog"}},
{"symfony/console/6.0.0/browse", false, []string{"symfony", "console", "6.0.0", "browse"}},
{"", false, nil},
{"/", false, nil},
// chi routes on the raw path when it differs from the canonical
// encoding of the decoded path, so segments arrive percent-encoded and
// must be decoded.
{
"nmap/7.91%2Bdfsg1%2Breally7.80%2Bdfsg1-2ubuntu0.1", true,
[]string{"nmap", "7.91+dfsg1+really7.80+dfsg1-2ubuntu0.1"},
},
{"%40babel/core/7.0.0", true, []string{"@babel", "core", "7.0.0"}},
// An encoded separator stays inside its segment rather than splitting.
{"vendor%2Fname/1.0.0", true, []string{"vendor/name", "1.0.0"}},
// Invalid escapes are passed through untouched.
{"lodash/1.0%zz", true, []string{"lodash", "1.0%zz"}},
// When chi routed on the already-decoded path, an escape that survived
// is part of the value: a version whose text is "1.0%2B" reaches here
// as "1.0%2B" and decoding it again would yield "1.0+".
{"nmap/1.0%2B", false, []string{"nmap", "1.0%2B"}},
}
for _, tt := range tests {
got := splitWildcardPath(tt.input, tt.encoded)
if len(got) != len(tt.want) {
t.Errorf("splitWildcardPath(%q, %v) = %v, want %v", tt.input, tt.encoded, got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("splitWildcardPath(%q, %v)[%d] = %q, want %q",
tt.input, tt.encoded, i, got[i], tt.want[i])
}
}
}
}
func TestValidatePackagePath(t *testing.T) {
tests := []struct {
name string
path string
wantErr bool
}{
{"simple", "lodash", false},
{"with version", "lodash/4.17.21", false},
{"npm scoped", "@babel/core/7.0.0", false},
{"composer namespaced", "symfony/console/6.0.0", false},
{"maven coordinates", "org.apache.commons/commons-lang3/3.12.0", false},
{"unicode", "café/1.0.0", false},
{"encoded plus in version", "nmap/7.91%2Bdfsg1-2ubuntu0.1", false},
{"empty", "", true},
{"null byte", "lodash\x00/4.17.21", true},
{"encoded null byte", "lodash/%00", true},
{"encoded newline", "lodash/1.0%0A", true},
{"parent segment", "lodash/../4.17.21", true},
{"encoded parent segment", "lodash/%2E%2E/4.17.21", true},
// A decoded segment can contain slashes, so traversal can hide inside
// one segment. Registries interpolate the resolved name straight into
// an upstream URL, and Go sends dot-segments verbatim.
{"traversal inside one segment", "pkg%2F..%2F..%2Fadmin", true},
{"traversal via encoded dots and slash", "pkg%2f%2e%2e%2fadmin", true},
{"encoded slash alone is allowed", "vendor%2Fname/1.0.0", false},
{"null byte suffix", "lodash\x00", true},
{"newline", "lodash\n4.17.21", true},
{"carriage return", "lodash\r", true},
{"escape", "lodash\x1b[31m", true},
{"delete", "lodash\x7f", true},
{"too long", strings.Repeat("a", maxPackagePathLen+1), true},
{"at limit", strings.Repeat("a", maxPackagePathLen), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// The verdict must not depend on whether chi routed on the raw or
// on the already-decoded path: an escape that reaches a handler
// undecoded is decoded by the upstream registry instead, so it is
// rejected either way.
for _, encoded := range []bool{false, true} {
err := validatePackagePath(tt.path, encoded)
if (err != nil) != tt.wantErr {
t.Errorf("validatePackagePath(%q, %v) error = %v, wantErr %v",
tt.path, encoded, err, tt.wantErr)
}
}
})
}
}
// TestPackagePathSegments drives the real router, which is what decides whether
// the wildcard still carries percent-encoding. Go decodes the request path
// itself unless the escaping is non-canonical, so the same version can arrive
// either way and only one of the two forms may be decoded again.
func TestPackagePathSegments(t *testing.T) {
tests := []struct {
name string
target string
want []string
}{
{"plain", "/pkg/npm/lodash/4.17.21", []string{"lodash", "4.17.21"}},
{"encoded plus", "/pkg/deb/nmap/7.91%2Bdfsg1-2ubuntu0.1", []string{"nmap", "7.91+dfsg1-2ubuntu0.1"}},
{"decoded plus", "/pkg/deb/nmap/7.91+dfsg1-2ubuntu0.1", []string{"nmap", "7.91+dfsg1-2ubuntu0.1"}},
// An encoded slash is one segment, not a separator.
{"encoded slash", "/pkg/composer/vendor%2Fname/1.0.0", []string{"vendor/name", "1.0.0"}},
{"question mark", "/pkg/npm/example/v1%3Fbuild", []string{"example", "v1?build"}},
// "1.0%252B" is the escaped form of the version "1.0%2B"; net/url
// already decoded it once, so it must not be decoded again.
{"literal percent escape", "/pkg/npm/example/1.0%252B", []string{"example", "1.0%2B"}},
{"browse suffix", "/pkg/deb/nmap/7.91%2Bdfsg1/browse", []string{"nmap", "7.91+dfsg1", "browse"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got []string
var gotErr error
router := chi.NewRouter()
router.Get("/pkg/{ecosystem}/*", func(_ http.ResponseWriter, r *http.Request) {
got, gotErr = packagePathSegments(r)
})
router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", tt.target, nil))
if gotErr != nil {
t.Fatalf("packagePathSegments(%q) failed: %v", tt.target, gotErr)
}
if len(got) != len(tt.want) {
t.Fatalf("segments for %q = %v, want %v", tt.target, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("segments for %q [%d] = %q, want %q", tt.target, i, got[i], tt.want[i])
}
}
})
}
}