Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-05-03 09:14:18 +01:00
|
|
|
"strings"
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/database"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
want []string
|
|
|
|
|
}{
|
|
|
|
|
{"lodash", []string{"lodash"}},
|
|
|
|
|
{"lodash/4.17.21", []string{"lodash", "4.17.21"}},
|
|
|
|
|
{"monolog/monolog", []string{"monolog", "monolog"}},
|
|
|
|
|
{"symfony/console/6.0.0/browse", []string{"symfony", "console", "6.0.0", "browse"}},
|
|
|
|
|
{"", nil},
|
|
|
|
|
{"/", nil},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
got := splitWildcardPath(tt.input)
|
|
|
|
|
if len(got) != len(tt.want) {
|
|
|
|
|
t.Errorf("splitWildcardPath(%q) = %v, want %v", tt.input, got, tt.want)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for i := range got {
|
|
|
|
|
if got[i] != tt.want[i] {
|
|
|
|
|
t.Errorf("splitWildcardPath(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-03 09:14:18 +01:00
|
|
|
|
|
|
|
|
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},
|
|
|
|
|
{"empty", "", true},
|
|
|
|
|
{"null byte", "lodash\x00/4.17.21", true},
|
|
|
|
|
{"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) {
|
|
|
|
|
err := validatePackagePath(tt.path)
|
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
|
|
|
t.Errorf("validatePackagePath(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|