diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 76d835d..fedf889 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -297,115 +297,6 @@ const docTemplate = `{ } } }, - "/api/package/{ecosystem}/{name}": { - "get": { - "description": "Returns enriched package metadata. URL-encode scoped names (e.g. @scope/name -\u003e %40scope%2Fname).", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get package metadata", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.PackageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/package/{ecosystem}/{name}/{version}": { - "get": { - "description": "Returns enriched package+version metadata and vulnerability data.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get version metadata and vulnerabilities", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.EnrichmentResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, "/api/packages": { "get": { "produces": [ @@ -505,108 +396,6 @@ const docTemplate = `{ } } }, - "/api/vulns/{ecosystem}/{name}": { - "get": { - "description": "Returns vulnerabilities for a package across versions, or for a specific version if provided.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get vulnerabilities for a package or version", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.VulnsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/vulns/{ecosystem}/{name}/{version}": { - "get": { - "description": "Returns vulnerabilities for a package across versions, or for a specific version if provided.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get vulnerabilities for a package or version", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.VulnsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, "/health": { "get": { "produces": [ @@ -715,29 +504,6 @@ const docTemplate = `{ } } }, - "server.EnrichmentResponse": { - "type": "object", - "properties": { - "is_outdated": { - "type": "boolean" - }, - "license_category": { - "type": "string" - }, - "package": { - "$ref": "#/definitions/server.PackageResponse" - }, - "version": { - "$ref": "#/definitions/server.VersionResponse" - }, - "vulnerabilities": { - "type": "array", - "items": { - "$ref": "#/definitions/server.VulnResponse" - } - } - } - }, "server.OutdatedPackage": { "type": "object", "properties": { @@ -949,84 +715,6 @@ const docTemplate = `{ "type": "integer" } } - }, - "server.VersionResponse": { - "type": "object", - "properties": { - "ecosystem": { - "type": "string" - }, - "integrity": { - "type": "string" - }, - "is_outdated": { - "type": "boolean" - }, - "license": { - "type": "string" - }, - "name": { - "type": "string" - }, - "published_at": { - "type": "string" - }, - "version": { - "type": "string" - }, - "yanked": { - "type": "boolean" - } - } - }, - "server.VulnResponse": { - "type": "object", - "properties": { - "cvss_score": { - "type": "number" - }, - "fixed_version": { - "type": "string" - }, - "id": { - "type": "string" - }, - "references": { - "type": "array", - "items": { - "type": "string" - } - }, - "severity": { - "type": "string" - }, - "summary": { - "type": "string" - } - } - }, - "server.VulnsResponse": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "ecosystem": { - "type": "string" - }, - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "vulnerabilities": { - "type": "array", - "items": { - "$ref": "#/definitions/server.VulnResponse" - } - } - } } } }` diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 8f0edb9..88df1e9 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -290,115 +290,6 @@ } } }, - "/api/package/{ecosystem}/{name}": { - "get": { - "description": "Returns enriched package metadata. URL-encode scoped names (e.g. @scope/name -\u003e %40scope%2Fname).", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get package metadata", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.PackageResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/package/{ecosystem}/{name}/{version}": { - "get": { - "description": "Returns enriched package+version metadata and vulnerability data.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get version metadata and vulnerabilities", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.EnrichmentResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, "/api/packages": { "get": { "produces": [ @@ -498,108 +389,6 @@ } } }, - "/api/vulns/{ecosystem}/{name}": { - "get": { - "description": "Returns vulnerabilities for a package across versions, or for a specific version if provided.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get vulnerabilities for a package or version", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.VulnsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/vulns/{ecosystem}/{name}/{version}": { - "get": { - "description": "Returns vulnerabilities for a package across versions, or for a specific version if provided.", - "produces": [ - "application/json" - ], - "tags": [ - "api" - ], - "summary": "Get vulnerabilities for a package or version", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.VulnsResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "string" - } - } - } - } - }, "/health": { "get": { "produces": [ @@ -708,29 +497,6 @@ } } }, - "server.EnrichmentResponse": { - "type": "object", - "properties": { - "is_outdated": { - "type": "boolean" - }, - "license_category": { - "type": "string" - }, - "package": { - "$ref": "#/definitions/server.PackageResponse" - }, - "version": { - "$ref": "#/definitions/server.VersionResponse" - }, - "vulnerabilities": { - "type": "array", - "items": { - "$ref": "#/definitions/server.VulnResponse" - } - } - } - }, "server.OutdatedPackage": { "type": "object", "properties": { @@ -942,84 +708,6 @@ "type": "integer" } } - }, - "server.VersionResponse": { - "type": "object", - "properties": { - "ecosystem": { - "type": "string" - }, - "integrity": { - "type": "string" - }, - "is_outdated": { - "type": "boolean" - }, - "license": { - "type": "string" - }, - "name": { - "type": "string" - }, - "published_at": { - "type": "string" - }, - "version": { - "type": "string" - }, - "yanked": { - "type": "boolean" - } - } - }, - "server.VulnResponse": { - "type": "object", - "properties": { - "cvss_score": { - "type": "number" - }, - "fixed_version": { - "type": "string" - }, - "id": { - "type": "string" - }, - "references": { - "type": "array", - "items": { - "type": "string" - } - }, - "severity": { - "type": "string" - }, - "summary": { - "type": "string" - } - } - }, - "server.VulnsResponse": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "ecosystem": { - "type": "string" - }, - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "vulnerabilities": { - "type": "array", - "items": { - "$ref": "#/definitions/server.VulnResponse" - } - } - } } } } \ No newline at end of file diff --git a/internal/handler/composer.go b/internal/handler/composer.go index 3b76a9c..b9edbdd 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -128,7 +128,9 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R } // rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy. -// If cooldown is enabled, versions published too recently are filtered out. +// If the metadata uses the minified Composer v2 format, it is expanded first so +// that every version entry contains all fields. If cooldown is enabled, versions +// published too recently are filtered out. func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) { var metadata map[string]any if err := json.Unmarshal(body, &metadata); err != nil { @@ -140,18 +142,63 @@ func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) { return body, nil } + minified := metadata["minified"] == "composer/2.0" + for packageName, versions := range packages { versionList, ok := versions.([]any) if !ok { continue } + if minified { + versionList = expandMinifiedVersions(versionList) + } + packages[packageName] = h.filterAndRewriteVersions(packageName, versionList) } + delete(metadata, "minified") + return json.Marshal(metadata) } +// expandMinifiedVersions expands the Composer v2 minified format where each +// version entry only contains fields that differ from the previous entry. +// The "~dev" sentinel string resets the inheritance chain. +func expandMinifiedVersions(versionList []any) []any { + expanded := make([]any, 0, len(versionList)) + inherited := map[string]any{} + + for _, v := range versionList { + // The "~dev" sentinel resets the inheritance chain for dev versions. + if s, ok := v.(string); ok && s == "~dev" { + inherited = map[string]any{} + continue + } + + vmap, ok := v.(map[string]any) + if !ok { + continue + } + + // Merge inherited fields into a new map, then overlay current fields. + merged := make(map[string]any, len(inherited)+len(vmap)) + for k, val := range inherited { + merged[k] = val + } + for k, val := range vmap { + merged[k] = val + } + + // Update inherited state for next iteration. + inherited = merged + + expanded = append(expanded, merged) + } + + return expanded +} + // filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs // for a single package's version list. func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any { diff --git a/internal/handler/composer_test.go b/internal/handler/composer_test.go index 567515b..89a4c33 100644 --- a/internal/handler/composer_test.go +++ b/internal/handler/composer_test.go @@ -50,6 +50,201 @@ func TestComposerRewriteMetadata(t *testing.T) { } } +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"]) + } + } +} + func TestComposerRewriteMetadataCooldown(t *testing.T) { now := time.Now() old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339) diff --git a/internal/server/api.go b/internal/server/api.go index e9b91be..e687f6d 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -135,36 +135,59 @@ type BulkResponse struct { Packages map[string]*PackageResponse `json:"packages"` } -// HandleGetPackage handles GET /api/package/{ecosystem}/{name} -// @Summary Get package metadata -// @Description Returns enriched package metadata. URL-encode scoped names (e.g. @scope/name -> %40scope%2Fname). -// @Tags api -// @Produce json -// @Param ecosystem path string true "Ecosystem" -// @Param name path string true "Package name" -// @Success 200 {object} PackageResponse -// @Failure 400 {string} string -// @Failure 404 {string} string -// @Failure 500 {string} string -// @Router /api/package/{ecosystem}/{name} [get] -func (h *APIHandler) HandleGetPackage(w http.ResponseWriter, r *http.Request) { +// HandlePackagePath dispatches /api/package/{ecosystem}/* to the appropriate handler. +// Resolves namespaced package names (Composer vendor/name, npm @scope/name) from the path. +func (h *APIHandler) HandlePackagePath(w http.ResponseWriter, r *http.Request) { ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) - if ecosystem == "" || name == "" { + if ecosystem == "" || len(segments) == 0 { http.Error(w, "ecosystem and name are required", http.StatusBadRequest) return } - // Handle scoped npm packages (e.g., @scope/name) - if strings.HasPrefix(name, "@") { - // The path is split, so we need to get the rest - rest := chi.URLParam(r, "rest") - if rest != "" { - name = name + "/" + rest + // For the API, we don't have a DB to resolve names, so we use a heuristic: + // the last segment that looks like a version (contains a digit) is the version, + // everything before it is the name. If no version-like segment, it's all name. + // + // With 1 segment: package lookup (name only) + // With 2+ segments: last segment is version, rest is name + // Exception: if this is a namespaced ecosystem and we have exactly 2 segments, + // it could be vendor/name with no version. The enrichment service handles + // both cases (it will try to look up the package either way). + if len(segments) == 1 { + h.getPackage(w, r, ecosystem, segments[0]) + return + } + + // Try the full path as a package name first via enrichment. + // If it resolves, this is a package-only lookup. + fullName := strings.Join(segments, "/") + info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, fullName) + if err == nil && info != nil { + resp := &PackageResponse{ + Ecosystem: info.Ecosystem, + Name: info.Name, + LatestVersion: info.LatestVersion, + License: info.License, + LicenseCategory: string(h.enrichment.CategorizeLicense(info.License)), + Description: info.Description, + Homepage: info.Homepage, + Repository: info.Repository, + RegistryURL: info.RegistryURL, } + writeJSON(w, resp) + return } + // Otherwise, last segment is the version. + name := strings.Join(segments[:len(segments)-1], "/") + version := segments[len(segments)-1] + h.getVersion(w, r, ecosystem, name, version) +} + +func (h *APIHandler) getPackage(w http.ResponseWriter, r *http.Request, ecosystem, name string) { info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, name) if err != nil { http.Error(w, "failed to enrich package", http.StatusInternalServerError) @@ -191,28 +214,7 @@ func (h *APIHandler) HandleGetPackage(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } -// HandleGetVersion handles GET /api/package/{ecosystem}/{name}/{version} -// @Summary Get version metadata and vulnerabilities -// @Description Returns enriched package+version metadata and vulnerability data. -// @Tags api -// @Produce json -// @Param ecosystem path string true "Ecosystem" -// @Param name path string true "Package name" -// @Param version path string true "Version" -// @Success 200 {object} EnrichmentResponse -// @Failure 400 {string} string -// @Failure 500 {string} string -// @Router /api/package/{ecosystem}/{name}/{version} [get] -func (h *APIHandler) HandleGetVersion(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") - - if ecosystem == "" || name == "" || version == "" { - http.Error(w, "ecosystem, name, and version are required", http.StatusBadRequest) - return - } - +func (h *APIHandler) getVersion(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) { result, err := h.enrichment.EnrichFull(r.Context(), ecosystem, name, version) if err != nil { http.Error(w, "failed to enrich version", http.StatusInternalServerError) @@ -267,32 +269,31 @@ func (h *APIHandler) HandleGetVersion(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } -// HandleGetVulns handles GET /api/vulns/{ecosystem}/{name} -// @Summary Get vulnerabilities for a package or version -// @Description Returns vulnerabilities for a package across versions, or for a specific version if provided. -// @Tags api -// @Produce json -// @Param ecosystem path string true "Ecosystem" -// @Param name path string true "Package name" -// @Param version path string false "Version" -// @Success 200 {object} VulnsResponse -// @Failure 400 {string} string -// @Failure 500 {string} string -// @Router /api/vulns/{ecosystem}/{name} [get] -// @Router /api/vulns/{ecosystem}/{name}/{version} [get] -func (h *APIHandler) HandleGetVulns(w http.ResponseWriter, r *http.Request) { +// HandleVulnsPath dispatches /api/vulns/{ecosystem}/* to the vulns handler. +// Supports both {name} and {name}/{version} paths with namespaced package names. +func (h *APIHandler) HandleVulnsPath(w http.ResponseWriter, r *http.Request) { ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) - if ecosystem == "" || name == "" { + if ecosystem == "" || len(segments) == 0 { http.Error(w, "ecosystem and name are required", http.StatusBadRequest) return } - // If no version specified, use "0" to get all vulnerabilities - if version == "" { - version = "0" + // Last segment could be a version. Try full path as name first, + // then split off the last segment as version. + name := strings.Join(segments, "/") + version := "0" + + if len(segments) > 1 { + // Try enrichment with the full path as name. + // If it doesn't resolve, assume last segment is version. + info, err := h.enrichment.EnrichPackage(r.Context(), ecosystem, name) + if err != nil || info == nil { + name = strings.Join(segments[:len(segments)-1], "/") + version = segments[len(segments)-1] + } } vulns, err := h.enrichment.CheckVulnerabilities(r.Context(), ecosystem, name, version) diff --git a/internal/server/api_test.go b/internal/server/api_test.go index 96cce9e..548f324 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -31,55 +31,37 @@ func TestNewAPIHandler(t *testing.T) { } } -func TestHandleGetPackage_MissingParams(t *testing.T) { +func TestHandlePackagePath_MissingParams(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) svc := enrichment.New(logger) h := NewAPIHandler(svc, nil) - req := httptest.NewRequest("GET", "/api/package//", nil) - req.SetPathValue("ecosystem", "") - req.SetPathValue("name", "") + r := chi.NewRouter() + r.Get("/api/package/{ecosystem}/*", h.HandlePackagePath) + req := httptest.NewRequest("GET", "/api/package//", nil) w := httptest.NewRecorder() - h.HandleGetPackage(w, req) + r.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound { + t.Errorf("expected status 400 or 404, got %d", w.Code) } } -func TestHandleGetVersion_MissingParams(t *testing.T) { +func TestHandleVulnsPath_MissingParams(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) svc := enrichment.New(logger) h := NewAPIHandler(svc, nil) - req := httptest.NewRequest("GET", "/api/package///", nil) - req.SetPathValue("ecosystem", "") - req.SetPathValue("name", "") - req.SetPathValue("version", "") - - w := httptest.NewRecorder() - h.HandleGetVersion(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) - } -} - -func TestHandleGetVulns_MissingParams(t *testing.T) { - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - svc := enrichment.New(logger) - h := NewAPIHandler(svc, nil) + r := chi.NewRouter() + r.Get("/api/vulns/{ecosystem}/*", h.HandleVulnsPath) req := httptest.NewRequest("GET", "/api/vulns//", nil) - req.SetPathValue("ecosystem", "") - req.SetPathValue("name", "") - w := httptest.NewRecorder() - h.HandleGetVulns(w, req) + r.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound { + t.Errorf("expected status 400 or 404, got %d", w.Code) } } diff --git a/internal/server/browse.go b/internal/server/browse.go index 372df50..c3ec9f7 100644 --- a/internal/server/browse.go +++ b/internal/server/browse.go @@ -57,10 +57,85 @@ type BrowseFileInfo struct { // @Failure 404 {string} string // @Failure 500 {string} string // @Router /api/browse/{ecosystem}/{name}/{version} [get] -func (s *Server) handleBrowseList(w http.ResponseWriter, r *http.Request) { +// handleBrowsePath dispatches /api/browse/{ecosystem}/* to the appropriate browse handler. +// It resolves namespaced package names by consulting the database. +// +// Supported paths: +// +// {name}/{version} -> browse list +// {name}/{version}/file/{path} -> browse file +func (s *Server) handleBrowsePath(w http.ResponseWriter, r *http.Request) { ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) + + if ecosystem == "" || len(segments) < 2 { + http.Error(w, "ecosystem, name, and version required", http.StatusBadRequest) + return + } + + // Check for /file/ in the path for browse file requests. + fileIdx := -1 + for i, seg := range segments { + if seg == "file" && i > 0 { + fileIdx = i + break + } + } + + if fileIdx >= 0 { + // Everything before "file" is name+version, everything after is the file path. + nameVersionSegments := segments[:fileIdx] + filePath := strings.Join(segments[fileIdx+1:], "/") + + name, rest := resolvePackageName(s.db, ecosystem, nameVersionSegments) + if name == "" && len(nameVersionSegments) >= 2 { + name = strings.Join(nameVersionSegments[:len(nameVersionSegments)-1], "/") + rest = nameVersionSegments[len(nameVersionSegments)-1:] + } + if len(rest) != 1 { + http.Error(w, "not found", http.StatusNotFound) + return + } + s.browseFile(w, r, ecosystem, name, rest[0], filePath) + return + } + + // No /file/ segment: this is a browse list. + name, rest := resolvePackageName(s.db, ecosystem, segments) + if name == "" && len(segments) >= 2 { + name = strings.Join(segments[:len(segments)-1], "/") + rest = segments[len(segments)-1:] + } + if len(rest) != 1 { + http.Error(w, "not found", http.StatusNotFound) + return + } + s.browseList(w, r, ecosystem, name, rest[0]) +} + +// handleComparePath dispatches /api/compare/{ecosystem}/* to the compare handler. +// Supported paths: {name}/{fromVersion}/{toVersion} +func (s *Server) handleComparePath(w http.ResponseWriter, r *http.Request) { + ecosystem := chi.URLParam(r, "ecosystem") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) + + if ecosystem == "" || len(segments) < 3 { + http.Error(w, "ecosystem, name, fromVersion, and toVersion required", http.StatusBadRequest) + return + } + + // The last two segments are fromVersion and toVersion. + // Everything before that is the package name. + name := strings.Join(segments[:len(segments)-2], "/") + fromVersion := segments[len(segments)-2] + toVersion := segments[len(segments)-1] + + s.compareDiff(w, r, ecosystem, name, fromVersion, toVersion) +} + +func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) { dirPath := r.URL.Query().Get("path") // Get the artifact for this version @@ -152,13 +227,7 @@ func (s *Server) handleBrowseList(w http.ResponseWriter, r *http.Request) { // @Failure 404 {string} string // @Failure 500 {string} string // @Router /api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get] -func (s *Server) handleBrowseFile(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") - - // Get the wildcard path - filePath := chi.URLParam(r, "*") +func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filePath string) { if filePath == "" { http.Error(w, "file path required", http.StatusBadRequest) return @@ -345,24 +414,7 @@ type BrowseSourceData struct { Version string } -// handleBrowseSource renders the source code browser UI. -// GET /package/{ecosystem}/{name}/{version}/browse -func (s *Server) handleBrowseSource(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") - - data := BrowseSourceData{ - Ecosystem: ecosystem, - PackageName: name, - Version: version, - } - - if err := s.templates.Render(w, "browse_source", data); err != nil { - s.logger.Error("failed to render browse source page", "error", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - } -} +// handleBrowseSource is now showBrowseSource in server.go, dispatched via handlePackagePath. // handleCompareDiff compares two versions and returns a diff. // GET /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} @@ -378,12 +430,7 @@ func (s *Server) handleBrowseSource(w http.ResponseWriter, r *http.Request) { // @Failure 404 {string} string // @Failure 500 {string} string // @Router /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get] -func (s *Server) handleCompareDiff(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - fromVersion := chi.URLParam(r, "fromVersion") - toVersion := chi.URLParam(r, "toVersion") - +func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) { // Get artifacts for both versions fromPURL := purl.MakePURLString(ecosystem, name, fromVersion) toPURL := purl.MakePURLString(ecosystem, name, toVersion) @@ -475,34 +522,4 @@ type ComparePageData struct { ToVersion string } -// handleComparePage renders the version comparison UI. -// GET /package/{ecosystem}/{name}/compare/{versions} -// where {versions} is in format "fromVersion...toVersion" -func (s *Server) handleComparePage(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - versions := chi.URLParam(r, "versions") - - // Parse versions (format: "1.0.0...2.0.0") - const compareVersionParts = 2 - parts := strings.Split(versions, "...") - if len(parts) != compareVersionParts { - http.Error(w, "invalid version format, use: version1...version2", http.StatusBadRequest) - return - } - - fromVersion := parts[0] - toVersion := parts[1] - - data := ComparePageData{ - Ecosystem: ecosystem, - PackageName: name, - FromVersion: fromVersion, - ToVersion: toVersion, - } - - if err := s.templates.Render(w, "compare_versions", data); err != nil { - s.logger.Error("failed to render compare page", "error", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - } -} +// handleComparePage is now showComparePage in server.go, dispatched via handlePackagePath. diff --git a/internal/server/resolve.go b/internal/server/resolve.go new file mode 100644 index 0000000..479ede6 --- /dev/null +++ b/internal/server/resolve.go @@ -0,0 +1,41 @@ +package server + +import ( + "strings" + + "github.com/git-pkgs/proxy/internal/database" +) + +// resolvePackageName determines the package name from a wildcard path by +// checking the database. This handles namespaced packages like Composer's +// vendor/name format where the package name contains a slash. +// +// It tries the full path as a package name first. If not found, it splits +// off the last segment as a non-name suffix (version, action, etc.) and +// tries again, working backwards until a match is found or segments run out. +// +// Returns the package name and the remaining path segments after the name. +// If no package is found, returns empty name and the original segments. +func resolvePackageName(db *database.DB, ecosystem string, segments []string) (name string, rest []string) { + // Try increasingly longer prefixes as the package name. + // Start with the longest possible name (all segments) and work down. + for i := len(segments); i >= 1; i-- { + candidate := strings.Join(segments[:i], "/") + pkg, err := db.GetPackageByEcosystemName(ecosystem, candidate) + if err == nil && pkg != nil { + return candidate, segments[i:] + } + } + + return "", segments +} + +// splitWildcardPath splits a chi wildcard path value into segments, +// trimming any leading/trailing slashes. +func splitWildcardPath(path string) []string { + path = strings.Trim(path, "/") + if path == "" { + return nil + } + return strings.Split(path, "/") +} diff --git a/internal/server/resolve_test.go b/internal/server/resolve_test.go new file mode 100644 index 0000000..427c2cb --- /dev/null +++ b/internal/server/resolve_test.go @@ -0,0 +1,120 @@ +package server + +import ( + "os" + "path/filepath" + "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]) + } + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index bc3187b..090e786 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -43,6 +43,7 @@ import ( "log/slog" "net/http" "strconv" + "strings" "time" swaggerdoc "github.com/git-pkgs/proxy/docs/swagger" @@ -218,30 +219,22 @@ func (s *Server) Start() error { r.Get("/install", s.handleInstall) r.Get("/search", s.handleSearch) r.Get("/packages", s.handlePackagesList) - r.Get("/package/{ecosystem}/{name}", s.handlePackageShow) - r.Get("/package/{ecosystem}/{name}/{version}", s.handleVersionShow) - r.Get("/package/{ecosystem}/{name}/{version}/browse", s.handleBrowseSource) + r.Get("/package/{ecosystem}/*", s.handlePackagePath) // API endpoints for enrichment data enrichSvc := enrichment.New(s.logger) apiHandler := NewAPIHandler(enrichSvc, s.db) - r.Get("/api/package/{ecosystem}/{name}", apiHandler.HandleGetPackage) - r.Get("/api/package/{ecosystem}/{name}/{version}", apiHandler.HandleGetVersion) - r.Get("/api/vulns/{ecosystem}/{name}", apiHandler.HandleGetVulns) - r.Get("/api/vulns/{ecosystem}/{name}/{version}", apiHandler.HandleGetVulns) + r.Get("/api/package/{ecosystem}/*", apiHandler.HandlePackagePath) + r.Get("/api/vulns/{ecosystem}/*", apiHandler.HandleVulnsPath) r.Post("/api/outdated", apiHandler.HandleOutdated) r.Post("/api/bulk", apiHandler.HandleBulkLookup) r.Get("/api/search", apiHandler.HandleSearch) r.Get("/api/packages", apiHandler.HandlePackagesList) - // Archive browsing endpoints - r.Get("/api/browse/{ecosystem}/{name}/{version}", s.handleBrowseList) - r.Get("/api/browse/{ecosystem}/{name}/{version}/file/*", s.handleBrowseFile) - - // Version comparison endpoints - r.Get("/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}", s.handleCompareDiff) - r.Get("/package/{ecosystem}/{name}/compare/{versions}", s.handleComparePage) + // Archive browsing and comparison endpoints also use wildcard for namespaced packages + r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) + r.Get("/api/compare/{ecosystem}/*", s.handleComparePath) s.http = &http.Server{ Addr: s.cfg.Listen, @@ -592,15 +585,71 @@ func (s *Server) handlePackagesList(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handlePackageShow(w http.ResponseWriter, r *http.Request) { +// handlePackagePath dispatches wildcard package routes to the appropriate handler. +// It resolves namespaced package names (e.g., Composer vendor/name) by consulting +// the database to determine which path segments are part of the package name. +// +// Supported paths: +// +// {name} -> package show +// {name}/{version} -> version show +// {name}/{version}/browse -> browse source +// {name}/compare/{v1}...{v2} -> compare versions +func (s *Server) handlePackagePath(w http.ResponseWriter, r *http.Request) { ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") + wildcard := chi.URLParam(r, "*") + segments := splitWildcardPath(wildcard) - if ecosystem == "" || name == "" { - http.Error(w, "ecosystem and name required", http.StatusBadRequest) + if ecosystem == "" || len(segments) == 0 { + http.Error(w, "ecosystem and package name required", http.StatusBadRequest) return } + // Check for compare route: {name}/compare/{versions} + for i, seg := range segments { + if seg == "compare" && i > 0 && i < len(segments)-1 { + name := strings.Join(segments[:i], "/") + versions := strings.Join(segments[i+1:], "/") + s.showComparePage(w, ecosystem, name, versions) + return + } + } + + // Check for browse suffix + browse := false + if len(segments) > 1 && segments[len(segments)-1] == "browse" { + browse = true + segments = segments[:len(segments)-1] + } + + // Resolve package name from the remaining segments using DB lookup. + name, rest := resolvePackageName(s.db, ecosystem, segments) + + if name == "" { + // No package found in DB. Fall back to heuristic: assume the last + // segment is a version (if present) and everything else is the name. + if len(segments) == 1 { + // Single segment, no DB match: try package show (will 404). + s.showPackage(w, ecosystem, segments[0]) + return + } + name = strings.Join(segments[:len(segments)-1], "/") + rest = segments[len(segments)-1:] + } + + switch { + case len(rest) == 0 && !browse: + s.showPackage(w, ecosystem, name) + case len(rest) == 1 && browse: + s.showBrowseSource(w, ecosystem, name, rest[0]) + case len(rest) == 1: + s.showVersion(w, ecosystem, name, rest[0]) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +func (s *Server) showPackage(w http.ResponseWriter, ecosystem, name string) { pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name) if err != nil { s.logger.Error("failed to get package", "error", err, "ecosystem", ecosystem, "name", name) @@ -636,16 +685,7 @@ func (s *Server) handlePackageShow(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleVersionShow(w http.ResponseWriter, r *http.Request) { - ecosystem := chi.URLParam(r, "ecosystem") - name := chi.URLParam(r, "name") - version := chi.URLParam(r, "version") - - if ecosystem == "" || name == "" || version == "" { - http.Error(w, "ecosystem, name, and version required", http.StatusBadRequest) - return - } - +func (s *Server) showVersion(w http.ResponseWriter, ecosystem, name, version string) { pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name) if err != nil || pkg == nil { s.logger.Error("failed to get package", "error", err) @@ -675,7 +715,6 @@ func (s *Server) handleVersionShow(w http.ResponseWriter, r *http.Request) { isOutdated := pkg.LatestVersion.Valid && pkg.LatestVersion.String != version - // Check if any artifact is cached hasCached := false for _, art := range artifacts { if art.StoragePath.Valid { @@ -699,6 +738,40 @@ func (s *Server) handleVersionShow(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) showBrowseSource(w http.ResponseWriter, ecosystem, name, version string) { + data := BrowseSourceData{ + Ecosystem: ecosystem, + PackageName: name, + Version: version, + } + + if err := s.templates.Render(w, "browse_source", data); err != nil { + s.logger.Error("failed to render browse source page", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } +} + +func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, versions string) { + const compareVersionParts = 2 + parts := strings.Split(versions, "...") + if len(parts) != compareVersionParts { + http.Error(w, "invalid version format, use: version1...version2", http.StatusBadRequest) + return + } + + data := ComparePageData{ + Ecosystem: ecosystem, + PackageName: name, + FromVersion: parts[0], + ToVersion: parts[1], + } + + if err := s.templates.Render(w, "compare_versions", data); err != nil { + s.logger.Error("failed to render compare page", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } +} + // handleHealth responds with a simple health check. // @Summary Health check // @Tags meta diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7e56f2c..f0c65cc 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -101,13 +101,9 @@ func newTestServer(t *testing.T) *testServer { r.Get("/openapi.json", s.handleOpenAPIJSON) r.Mount("/static", http.StripPrefix("/static/", staticHandler())) r.Get("/search", s.handleSearch) - r.Get("/package/{ecosystem}/{name}", s.handlePackageShow) - r.Get("/package/{ecosystem}/{name}/{version}", s.handleVersionShow) - r.Get("/package/{ecosystem}/{name}/{version}/browse", s.handleBrowseSource) - r.Get("/api/browse/{ecosystem}/{name}/{version}", s.handleBrowseList) - r.Get("/api/browse/{ecosystem}/{name}/{version}/file/*", s.handleBrowseFile) - r.Get("/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}", s.handleCompareDiff) - r.Get("/package/{ecosystem}/{name}/compare/{versions}", s.handleComparePage) + r.Get("/package/{ecosystem}/*", s.handlePackagePath) + r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) + r.Get("/api/compare/{ecosystem}/*", s.handleComparePath) r.Get("/", s.handleRoot) r.Get("/install", s.handleInstall) r.Get("/packages", s.handlePackagesList) @@ -701,6 +697,113 @@ func TestPackageShowPage_WithLicense(t *testing.T) { } } +func TestComposerNamespacedPackageRoutes(t *testing.T) { + ts := newTestServer(t) + defer ts.close() + + // Seed two Composer packages with vendor/name format. + for _, p := range []struct { + purl, name, versionPURL string + }{ + {"pkg:composer/monolog/monolog", "monolog/monolog", "pkg:composer/monolog/monolog@3.0.0"}, + {"pkg:composer/symfony/console", "symfony/console", "pkg:composer/symfony/console@6.0.0"}, + } { + if err := ts.db.UpsertPackage(&database.Package{ + PURL: p.purl, Ecosystem: "composer", Name: p.name, + }); err != nil { + t.Fatalf("failed to upsert package %s: %v", p.name, err) + } + if err := ts.db.UpsertVersion(&database.Version{ + PURL: p.versionPURL, PackagePURL: p.purl, + }); err != nil { + t.Fatalf("failed to upsert version for %s: %v", p.name, err) + } + } + + tests := []struct { + name string + url string + want string + }{ + {"package show", "/package/composer/monolog/monolog", "monolog/monolog"}, + {"version show", "/package/composer/symfony/console/6.0.0", "symfony/console"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.url, nil) + w := httptest.NewRecorder() + ts.handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("GET %s: expected status 200, got %d", tt.url, w.Code) + } + if !strings.Contains(w.Body.String(), tt.want) { + t.Errorf("GET %s: expected body to contain %q", tt.url, tt.want) + } + }) + } +} + +func TestNamespacedPackageRoutes(t *testing.T) { + ts := newTestServer(t) + defer ts.close() + + // Seed packages from ecosystems that use slashes in package names. + pkgs := []struct { + purl, ecosystem, name, versionPURL string + }{ + // npm scoped packages + {"pkg:npm/%40babel/core", "npm", "@babel/core", "pkg:npm/%40babel/core@7.24.0"}, + // Go modules (multi-segment paths) + {"pkg:golang/github.com/stretchr/testify", "golang", "github.com/stretchr/testify", "pkg:golang/github.com/stretchr/testify@1.9.0"}, + // OCI/container images + {"pkg:oci/library/nginx", "oci", "library/nginx", "pkg:oci/library/nginx@sha256:abc123"}, + // Conda (channel/name) + {"pkg:conda/conda-forge/numpy", "conda", "conda-forge/numpy", "pkg:conda/conda-forge/numpy@1.26.4"}, + // Conan (name/version@user/channel) + {"pkg:conan/zlib/1.2.13@demo/stable", "conan", "zlib/1.2.13@demo/stable", "pkg:conan/zlib/1.2.13@demo/stable@rev1"}, + } + + for _, p := range pkgs { + if err := ts.db.UpsertPackage(&database.Package{ + PURL: p.purl, Ecosystem: p.ecosystem, Name: p.name, + }); err != nil { + t.Fatalf("failed to upsert package %s: %v", p.name, err) + } + if err := ts.db.UpsertVersion(&database.Version{ + PURL: p.versionPURL, PackagePURL: p.purl, + }); err != nil { + t.Fatalf("failed to upsert version for %s: %v", p.name, err) + } + } + + tests := []struct { + name string + url string + want int + }{ + {"npm scoped package show", "/package/npm/@babel/core", http.StatusOK}, + {"golang module show", "/package/golang/github.com/stretchr/testify", http.StatusOK}, + {"oci image show", "/package/oci/library/nginx", http.StatusOK}, + {"conda package show", "/package/conda/conda-forge/numpy", http.StatusOK}, + {"conan package show", "/package/conan/zlib/1.2.13@demo/stable", http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.url, nil) + w := httptest.NewRecorder() + ts.handler.ServeHTTP(w, req) + + if w.Code != tt.want { + t.Errorf("GET %s: expected status %d, got %d (body: %s)", + tt.url, tt.want, w.Code, w.Body.String()) + } + }) + } +} + func TestSearchPage_WithSeededResults(t *testing.T) { ts := newTestServer(t) defer ts.close()