mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-23 12:24:57 -04:00
241 lines
8.2 KiB
Go
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])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|