2026-03-04 19:00:31 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"log/slog"
|
2026-04-06 16:34:33 +01:00
|
|
|
"strings"
|
2026-03-04 19:00:31 +00:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/cooldown"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestComposerRewriteMetadata(t *testing.T) {
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input := `{
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["symfony/console"].([]any)
|
|
|
|
|
v := versions[0].(map[string]any)
|
|
|
|
|
dist := v["dist"].(map[string]any)
|
|
|
|
|
|
|
|
|
|
expected := "http://localhost:8080/composer/files/symfony/console/6.0.0/abc123.zip"
|
|
|
|
|
if dist["url"] != expected {
|
|
|
|
|
t.Errorf("dist url = %q, want %q", dist["url"], expected)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
func TestComposerRewriteMetadataExpandsMinified(t *testing.T) {
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Minified format: first version has all fields, subsequent versions
|
|
|
|
|
// only include fields that changed. The proxy must expand this so every
|
|
|
|
|
// version has all fields (including "name").
|
|
|
|
|
input := `{
|
|
|
|
|
"minified": "composer/2.0",
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"name": "symfony/console",
|
|
|
|
|
"description": "Symfony Console Component",
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "5.4.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/5.4.0/def456.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The minified key should be removed from output
|
|
|
|
|
if _, ok := result["minified"]; ok {
|
|
|
|
|
t.Error("expected minified key to be removed from output")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["symfony/console"].([]any)
|
|
|
|
|
|
|
|
|
|
// Second version should have inherited the "name" and "description" fields
|
|
|
|
|
v1 := versions[1].(map[string]any)
|
|
|
|
|
if v1["name"] != "symfony/console" {
|
|
|
|
|
t.Errorf("second version name = %v, want %q", v1["name"], "symfony/console")
|
|
|
|
|
}
|
|
|
|
|
if v1["description"] != "Symfony Console Component" {
|
|
|
|
|
t.Errorf("second version description = %v, want %q", v1["description"], "Symfony Console Component")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComposerRewriteMetadataMinifiedDevReset(t *testing.T) {
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The ~dev sentinel resets the inheritance chain for dev versions.
|
|
|
|
|
input := `{
|
|
|
|
|
"minified": "composer/2.0",
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"name": "symfony/console",
|
|
|
|
|
"description": "Symfony Console Component",
|
|
|
|
|
"license": ["MIT"],
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"~dev",
|
|
|
|
|
{
|
|
|
|
|
"name": "symfony/console",
|
|
|
|
|
"version": "dev-main",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/dev-main/xyz789.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["symfony/console"].([]any)
|
|
|
|
|
|
|
|
|
|
if len(versions) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 versions, got %d", len(versions))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dev version should NOT have inherited "license" or "description"
|
|
|
|
|
// from the tagged version (the ~dev sentinel resets inheritance).
|
|
|
|
|
devVersion := versions[1].(map[string]any)
|
|
|
|
|
if devVersion["version"] != "dev-main" {
|
|
|
|
|
t.Errorf("dev version = %v, want %q", devVersion["version"], "dev-main")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := devVersion["license"]; ok {
|
|
|
|
|
t.Error("dev version should not have inherited license field after ~dev reset")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := devVersion["description"]; ok {
|
|
|
|
|
t.Error("dev version should not have inherited description field after ~dev reset")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComposerRewriteMetadataCooldownPreservesNames(t *testing.T) {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
veryOld := now.Add(-20 * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
|
|
|
|
|
proxy := &Proxy{Logger: slog.Default()}
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Minified format where "name" only appears in first version.
|
|
|
|
|
// When cooldown filters the first version, remaining versions must
|
|
|
|
|
// still have the "name" field after expansion.
|
|
|
|
|
input := `{
|
|
|
|
|
"minified": "composer/2.0",
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"name": "symfony/console",
|
|
|
|
|
"description": "Symfony Console Component",
|
|
|
|
|
"version": "7.0.0",
|
|
|
|
|
"time": "` + recent + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/7.0.0.zip", "type": "zip"}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"time": "` + old + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/6.0.0.zip", "type": "zip"}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "5.0.0",
|
|
|
|
|
"time": "` + veryOld + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/5.0.0.zip", "type": "zip"}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["symfony/console"].([]any)
|
|
|
|
|
|
|
|
|
|
// v7.0.0 should be filtered by cooldown, leaving v6.0.0 and v5.0.0
|
|
|
|
|
if len(versions) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 versions after cooldown, got %d", len(versions))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Both remaining versions must have the "name" field
|
|
|
|
|
for _, v := range versions {
|
|
|
|
|
vmap := v.(map[string]any)
|
|
|
|
|
if vmap["name"] != "symfony/console" {
|
|
|
|
|
t.Errorf("version %v missing name field, got %v", vmap["version"], vmap["name"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 16:34:33 +01:00
|
|
|
func TestComposerRewriteDistURLGitHubZipball(t *testing.T) {
|
|
|
|
|
// GitHub zipball URLs end with a bare commit hash, no file extension.
|
|
|
|
|
// The proxy must produce a filename with .zip extension so that the
|
|
|
|
|
// archives library can detect the format when browsing source.
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
vmap := map[string]any{
|
|
|
|
|
"version": "v7.4.8",
|
|
|
|
|
"dist": map[string]any{
|
|
|
|
|
"url": "https://api.github.com/repos/symfony/asset/zipball/d2e2f014ccd6ec9fae8dbe6336a4164346a2a856",
|
|
|
|
|
"type": "zip",
|
|
|
|
|
"shasum": "",
|
|
|
|
|
"reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.rewriteDistURL(vmap, "symfony/asset", "v7.4.8")
|
|
|
|
|
|
|
|
|
|
dist := vmap["dist"].(map[string]any)
|
|
|
|
|
url := dist["url"].(string)
|
|
|
|
|
|
|
|
|
|
// The rewritten URL's filename must have a .zip extension
|
|
|
|
|
if !strings.HasSuffix(url, ".zip") {
|
|
|
|
|
t.Errorf("rewritten dist URL filename has no .zip extension: %s", url)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComposerRewriteMetadataGitHubZipballFilenames(t *testing.T) {
|
|
|
|
|
// End-to-end: metadata with GitHub zipball URLs should produce
|
|
|
|
|
// download URLs that end in .zip so browse source can open them.
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input := `{
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/config": [
|
|
|
|
|
{
|
|
|
|
|
"version": "v7.4.8",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://api.github.com/repos/symfony/config/zipball/c7369cc1da250fcbfe0c5a9d109e419661549c39",
|
|
|
|
|
"type": "zip",
|
|
|
|
|
"reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["symfony/config"].([]any)
|
|
|
|
|
v := versions[0].(map[string]any)
|
|
|
|
|
dist := v["dist"].(map[string]any)
|
|
|
|
|
url := dist["url"].(string)
|
|
|
|
|
|
|
|
|
|
if !strings.HasSuffix(url, ".zip") {
|
|
|
|
|
t.Errorf("rewritten URL should end in .zip, got %s", url)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComposerExpandMinifiedSharedDistReferences(t *testing.T) {
|
|
|
|
|
// When a minified version inherits the dist field from a previous version
|
|
|
|
|
// (i.e. it doesn't include its own dist), expanding + rewriting must not
|
|
|
|
|
// corrupt the dist URLs via shared map references.
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// In this minified payload, v5.3.0 does NOT include a dist field,
|
|
|
|
|
// so it inherits v5.4.0's dist. After expansion and URL rewriting,
|
|
|
|
|
// each version must have its own correct dist URL.
|
|
|
|
|
input := `{
|
|
|
|
|
"minified": "composer/2.0",
|
|
|
|
|
"packages": {
|
|
|
|
|
"vendor/pkg": [
|
|
|
|
|
{
|
|
|
|
|
"name": "vendor/pkg",
|
|
|
|
|
"version": "5.4.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://api.github.com/repos/vendor/pkg/zipball/aaa111",
|
|
|
|
|
"type": "zip",
|
|
|
|
|
"reference": "aaa111"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "5.3.0"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["vendor/pkg"].([]any)
|
|
|
|
|
if len(versions) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 versions, got %d", len(versions))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
v1 := versions[0].(map[string]any)
|
|
|
|
|
v2 := versions[1].(map[string]any)
|
|
|
|
|
|
|
|
|
|
dist1 := v1["dist"].(map[string]any)
|
|
|
|
|
dist2 := v2["dist"].(map[string]any)
|
|
|
|
|
|
|
|
|
|
url1 := dist1["url"].(string)
|
|
|
|
|
url2 := dist2["url"].(string)
|
|
|
|
|
|
|
|
|
|
// Each version must have its own URL with its own version in the path
|
|
|
|
|
if !strings.Contains(url1, "/5.4.0/") {
|
|
|
|
|
t.Errorf("v5.4.0 dist URL should contain /5.4.0/, got %s", url1)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(url2, "/5.3.0/") {
|
|
|
|
|
t.Errorf("v5.3.0 dist URL should contain /5.3.0/, got %s", url2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The two URLs must be different
|
|
|
|
|
if url1 == url2 {
|
|
|
|
|
t.Errorf("both versions have the same dist URL (shared reference bug): %s", url1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 19:00:31 +00:00
|
|
|
func TestComposerRewriteMetadataCooldown(t *testing.T) {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
|
|
|
|
|
proxy := &Proxy{Logger: slog.Default()}
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input := `{
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"version": "5.0.0",
|
|
|
|
|
"time": "` + old + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/5.0.0.zip", "type": "zip"}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"time": "` + recent + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/6.0.0.zip", "type": "zip"}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["symfony/console"].([]any)
|
|
|
|
|
|
|
|
|
|
if len(versions) != 1 {
|
|
|
|
|
t.Fatalf("expected 1 version after cooldown, got %d", len(versions))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
v := versions[0].(map[string]any)
|
|
|
|
|
if v["version"] != "5.0.0" {
|
|
|
|
|
t.Errorf("expected version 5.0.0, got %v", v["version"])
|
|
|
|
|
}
|
|
|
|
|
}
|