From 47bdfdba1715f54f80dbd0ab65fcc0a649b158fc Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sun, 30 Aug 2026 16:56:57 +0530 Subject: [PATCH 1/2] feat(mirror): accept inline SBOM API requests --- README.md | 7 +++++- cmd/proxy/main.go | 7 +++++- docs/configuration.md | 8 +++++++ internal/mirror/job.go | 10 ++++++--- internal/mirror/job_test.go | 19 ++++++++++++++++ internal/mirror/source.go | 18 +++++---------- internal/mirror/source_test.go | 33 ++++++++------------------- internal/server/mirror_api_test.go | 36 ++++++++++++++++++++++++++++++ 8 files changed, 97 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3a9444d..d1bdeba 100644 --- a/README.md +++ b/README.md @@ -644,6 +644,11 @@ curl -X POST http://localhost:8080/api/mirror \ -H "Content-Type: application/json" \ -d '{"purls": ["pkg:npm/lodash@4.17.21"]}' +# Start a mirror job from an inline CycloneDX or SPDX JSON SBOM +curl -X POST http://localhost:8080/api/mirror \ + -H "Content-Type: application/json" \ + -d '{"sbom":{"bomFormat":"CycloneDX","components":[{"purl":"pkg:npm/lodash@4.17.21"}]}}' + # Check job status curl http://localhost:8080/api/mirror/mirror-1 @@ -731,7 +736,7 @@ Recently cached: | Endpoint | Description | |----------|-------------| -| `POST /api/mirror` | Start a mirror job (JSON body with `purls`) | +| `POST /api/mirror` | Start a mirror job (JSON body with `purls` or an inline `sbom`) | | `GET /api/mirror/{id}` | Get job status and progress | | `DELETE /api/mirror/{id}` | Cancel a running job | diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 8a3bf85..03ac6ab 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -428,7 +428,12 @@ func runMirror() { var source mirror.Source switch { case *sbomPath != "": - source = &mirror.SBOMSource{Path: *sbomPath} + data, err := os.ReadFile(*sbomPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading SBOM %s: %v\n", *sbomPath, err) + os.Exit(1) + } + source = &mirror.SBOMSource{Data: data} case len(purls) > 0: source = &mirror.PURLSource{PURLs: purls} default: diff --git a/docs/configuration.md b/docs/configuration.md index de88498..7dc4bb7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -399,6 +399,14 @@ Or via environment variable: `PROXY_MIRROR_API=true`. When disabled, the endpoints are not registered and return 404. +Start a mirror job with either PURLs or an inline CycloneDX or SPDX JSON document: + +```bash +curl -X POST http://localhost:8080/api/mirror \ + -H "Content-Type: application/json" \ + -d '{"sbom":{"bomFormat":"CycloneDX","components":[{"purl":"pkg:npm/lodash@4.17.21"}]}}' +``` + ## Mirror Command The `proxy mirror` command pre-populates the cache from various sources. It accepts the same storage and database flags as `serve`. diff --git a/internal/mirror/job.go b/internal/mirror/job.go index fbae2a2..1d6db8b 100644 --- a/internal/mirror/job.go +++ b/internal/mirror/job.go @@ -3,6 +3,7 @@ package mirror import ( "context" "crypto/rand" + "encoding/json" "fmt" "sync" "time" @@ -35,8 +36,9 @@ type Job struct { // JobRequest is the JSON body for starting a mirror job via the API. type JobRequest struct { - PURLs []string `json:"purls,omitempty"` - Registry string `json:"registry,omitempty"` + PURLs []string `json:"purls,omitempty"` + SBOM json.RawMessage `json:"sbom,omitempty"` + Registry string `json:"registry,omitempty"` } // JobStore manages in-memory mirror jobs. @@ -192,10 +194,12 @@ func (js *JobStore) sourceFromRequest(req JobRequest) (Source, error) { //nolint switch { case len(req.PURLs) > 0: return &PURLSource{PURLs: req.PURLs}, nil + case len(req.SBOM) > 0: + return &SBOMSource{Data: req.SBOM}, nil case req.Registry != "": return nil, fmt.Errorf("registry mirroring is not yet implemented; use purls instead") default: - return nil, fmt.Errorf("request must include purls") + return nil, fmt.Errorf("request must include purls or sbom") } } diff --git a/internal/mirror/job_test.go b/internal/mirror/job_test.go index f7f2f1c..8f4747f 100644 --- a/internal/mirror/job_test.go +++ b/internal/mirror/job_test.go @@ -2,6 +2,7 @@ package mirror import ( "context" + "encoding/json" "testing" "time" ) @@ -100,6 +101,24 @@ func TestSourceFromRequestPURLs(t *testing.T) { } } +func TestSourceFromRequestSBOM(t *testing.T) { + m := setupTestMirror(t, 1) + js := NewJobStore(context.Background(), m) + sbom := json.RawMessage(`{"bomFormat":"CycloneDX","components":[]}`) + + source, err := js.sourceFromRequest(JobRequest{SBOM: sbom}) + if err != nil { + t.Fatalf("sourceFromRequest() error = %v", err) + } + sbomSource, ok := source.(*SBOMSource) + if !ok { + t.Fatalf("expected *SBOMSource, got %T", source) + } + if got := string(sbomSource.Data); got != string(sbom) { + t.Errorf("SBOM data = %q, want %q", got, sbom) + } +} + func TestSourceFromRequestRegistryRejected(t *testing.T) { m := setupTestMirror(t, 1) js := NewJobStore(context.Background(), m) diff --git a/internal/mirror/source.go b/internal/mirror/source.go index c583fb7..1f0e67f 100644 --- a/internal/mirror/source.go +++ b/internal/mirror/source.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "os" cdx "github.com/CycloneDX/cyclonedx-go" "github.com/git-pkgs/purl" @@ -94,16 +93,16 @@ func (s *PURLSource) fetchVersions(ctx context.Context, client *registries.Clien return result, nil } -// SBOMSource extracts package versions from a CycloneDX or SPDX SBOM file. +// SBOMSource extracts package versions from CycloneDX or SPDX SBOM data. type SBOMSource struct { - Path string + Data []byte RegClient *registries.Client } func (s *SBOMSource) Enumerate(ctx context.Context, fn func(PackageVersion) error) error { purls, err := s.extractPURLs() if err != nil { - return fmt.Errorf("reading SBOM %s: %w", s.Path, err) + return fmt.Errorf("parsing SBOM: %w", err) } inner := &PURLSource{PURLs: purls, RegClient: s.RegClient} @@ -111,23 +110,18 @@ func (s *SBOMSource) Enumerate(ctx context.Context, fn func(PackageVersion) erro } func (s *SBOMSource) extractPURLs() ([]string, error) { - data, err := os.ReadFile(s.Path) - if err != nil { - return nil, err - } - // Try CycloneDX first - if purls, err := extractCycloneDXPURLs(data); err == nil && len(purls) > 0 { + if purls, err := extractCycloneDXPURLs(s.Data); err == nil && len(purls) > 0 { return purls, nil } // Try SPDX JSON - if purls, err := extractSPDXJSONPURLs(data); err == nil && len(purls) > 0 { + if purls, err := extractSPDXJSONPURLs(s.Data); err == nil && len(purls) > 0 { return purls, nil } // Try SPDX tag-value - if purls, err := extractSPDXTVPURLs(data); err == nil && len(purls) > 0 { + if purls, err := extractSPDXTVPURLs(s.Data); err == nil && len(purls) > 0 { return purls, nil } diff --git a/internal/mirror/source_test.go b/internal/mirror/source_test.go index b0bb1be..6959231 100644 --- a/internal/mirror/source_test.go +++ b/internal/mirror/source_test.go @@ -3,8 +3,6 @@ package mirror import ( "context" "encoding/json" - "os" - "path/filepath" "testing" ) @@ -116,8 +114,7 @@ func TestSBOMSourceCycloneDXJSON(t *testing.T) { }, } - path := writeTempJSON(t, bom) - source := &SBOMSource{Path: path} + source := &SBOMSource{Data: marshalJSON(t, bom)} var items []PackageVersion err := source.Enumerate(context.Background(), func(pv PackageVersion) error { @@ -164,8 +161,7 @@ func TestSBOMSourceSPDXJSON(t *testing.T) { }, } - path := writeTempJSON(t, doc) - source := &SBOMSource{Path: path} + source := &SBOMSource{Data: marshalJSON(t, doc)} var items []PackageVersion err := source.Enumerate(context.Background(), func(pv PackageVersion) error { @@ -185,24 +181,19 @@ func TestSBOMSourceSPDXJSON(t *testing.T) { } } -func TestSBOMSourceNonexistentFile(t *testing.T) { - source := &SBOMSource{Path: "/nonexistent/sbom.json"} +func TestSBOMSourceEmptyData(t *testing.T) { + source := &SBOMSource{} err := source.Enumerate(context.Background(), func(pv PackageVersion) error { return nil }) if err == nil { - t.Fatal("expected error for nonexistent file") + t.Fatal("expected error for empty SBOM") } } func TestSBOMSourceInvalidFormat(t *testing.T) { - path := filepath.Join(t.TempDir(), "invalid.txt") - if err := os.WriteFile(path, []byte("this is not an SBOM"), 0644); err != nil { - t.Fatal(err) - } - - source := &SBOMSource{Path: path} + source := &SBOMSource{Data: []byte("this is not an SBOM")} err := source.Enumerate(context.Background(), func(pv PackageVersion) error { return nil }) @@ -216,11 +207,9 @@ func TestSBOMSourceEmptyCycloneDX(t *testing.T) { "bomFormat": "CycloneDX", "specVersion": "1.4", } - path := writeTempJSON(t, bom) - // This should fall through to SPDX parsing, which will also fail, // resulting in an error about not being able to parse - source := &SBOMSource{Path: path} + source := &SBOMSource{Data: marshalJSON(t, bom)} err := source.Enumerate(context.Background(), func(pv PackageVersion) error { return nil }) @@ -229,15 +218,11 @@ func TestSBOMSourceEmptyCycloneDX(t *testing.T) { } } -func writeTempJSON(t *testing.T, v any) string { +func marshalJSON(t *testing.T, v any) []byte { t.Helper() data, err := json.Marshal(v) if err != nil { t.Fatal(err) } - path := filepath.Join(t.TempDir(), "sbom.json") - if err := os.WriteFile(path, data, 0644); err != nil { - t.Fatal(err) - } - return path + return data } diff --git a/internal/server/mirror_api_test.go b/internal/server/mirror_api_test.go index 73b8731..9d2d8fd 100644 --- a/internal/server/mirror_api_test.go +++ b/internal/server/mirror_api_test.go @@ -71,6 +71,42 @@ func TestMirrorAPICreateJob(t *testing.T) { } } +func TestMirrorAPICreateJobFromInlineSBOM(t *testing.T) { + h := setupMirrorAPI(t) + + body, err := json.Marshal(mirror.JobRequest{ + SBOM: json.RawMessage(`{ + "bomFormat":"CycloneDX", + "specVersion":"1.4", + "components":[{ + "type":"library", + "name":"lodash", + "version":"4.17.21", + "purl":"pkg:npm/lodash@4.17.21" + }] + }`), + }) + if err != nil { + t.Fatalf("marshaling request: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/mirror", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.HandleCreate(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusAccepted, w.Body.String()) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + if resp["id"] == "" { + t.Error("expected non-empty job ID") + } +} + func TestMirrorAPICreateOversizedBody(t *testing.T) { h := setupMirrorAPI(t) From 3dd4bcc84604df7beff93d900893df660da76213 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sun, 30 Aug 2026 22:45:54 +0530 Subject: [PATCH 2/2] fix(mirror): address inline SBOM review feedback --- cmd/proxy/main.go | 2 +- internal/mirror/job.go | 2 ++ internal/mirror/job_test.go | 13 +++++++++++++ internal/mirror/source.go | 4 ++++ internal/mirror/source_test.go | 6 +++++- internal/server/mirror_api_test.go | 20 ++++++++++++++++++++ 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 03ac6ab..869afd0 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -433,7 +433,7 @@ func runMirror() { fmt.Fprintf(os.Stderr, "error reading SBOM %s: %v\n", *sbomPath, err) os.Exit(1) } - source = &mirror.SBOMSource{Data: data} + source = &mirror.SBOMSource{Data: data, Name: *sbomPath} case len(purls) > 0: source = &mirror.PURLSource{PURLs: purls} default: diff --git a/internal/mirror/job.go b/internal/mirror/job.go index 1d6db8b..70d7003 100644 --- a/internal/mirror/job.go +++ b/internal/mirror/job.go @@ -192,6 +192,8 @@ func (js *JobStore) runJob(ctx context.Context, cancel context.CancelFunc, job * func (js *JobStore) sourceFromRequest(req JobRequest) (Source, error) { //nolint:ireturn // interface return is the design switch { + case len(req.PURLs) > 0 && len(req.SBOM) > 0: + return nil, fmt.Errorf("request must include only one of purls or sbom") case len(req.PURLs) > 0: return &PURLSource{PURLs: req.PURLs}, nil case len(req.SBOM) > 0: diff --git a/internal/mirror/job_test.go b/internal/mirror/job_test.go index 8f4747f..c0ac3f1 100644 --- a/internal/mirror/job_test.go +++ b/internal/mirror/job_test.go @@ -119,6 +119,19 @@ func TestSourceFromRequestSBOM(t *testing.T) { } } +func TestSourceFromRequestRejectsPURLsAndSBOM(t *testing.T) { + m := setupTestMirror(t, 1) + js := NewJobStore(context.Background(), m) + + _, err := js.sourceFromRequest(JobRequest{ + PURLs: []string{"pkg:npm/lodash@4.17.21"}, + SBOM: json.RawMessage(`{"bomFormat":"CycloneDX","components":[]}`), + }) + if err == nil { + t.Fatal("expected error when both purls and sbom are provided") + } +} + func TestSourceFromRequestRegistryRejected(t *testing.T) { m := setupTestMirror(t, 1) js := NewJobStore(context.Background(), m) diff --git a/internal/mirror/source.go b/internal/mirror/source.go index 1f0e67f..4269264 100644 --- a/internal/mirror/source.go +++ b/internal/mirror/source.go @@ -96,12 +96,16 @@ func (s *PURLSource) fetchVersions(ctx context.Context, client *registries.Clien // SBOMSource extracts package versions from CycloneDX or SPDX SBOM data. type SBOMSource struct { Data []byte + Name string RegClient *registries.Client } func (s *SBOMSource) Enumerate(ctx context.Context, fn func(PackageVersion) error) error { purls, err := s.extractPURLs() if err != nil { + if s.Name != "" { + return fmt.Errorf("parsing SBOM %s: %w", s.Name, err) + } return fmt.Errorf("parsing SBOM: %w", err) } diff --git a/internal/mirror/source_test.go b/internal/mirror/source_test.go index 6959231..fe56ca4 100644 --- a/internal/mirror/source_test.go +++ b/internal/mirror/source_test.go @@ -3,6 +3,7 @@ package mirror import ( "context" "encoding/json" + "strings" "testing" ) @@ -193,13 +194,16 @@ func TestSBOMSourceEmptyData(t *testing.T) { } func TestSBOMSourceInvalidFormat(t *testing.T) { - source := &SBOMSource{Data: []byte("this is not an SBOM")} + source := &SBOMSource{Data: []byte("this is not an SBOM"), Name: "invalid.sbom"} err := source.Enumerate(context.Background(), func(pv PackageVersion) error { return nil }) if err == nil { t.Fatal("expected error for invalid SBOM") } + if !strings.Contains(err.Error(), "invalid.sbom") { + t.Errorf("error = %q, want source name", err) + } } func TestSBOMSourceEmptyCycloneDX(t *testing.T) { diff --git a/internal/server/mirror_api_test.go b/internal/server/mirror_api_test.go index 9d2d8fd..c960348 100644 --- a/internal/server/mirror_api_test.go +++ b/internal/server/mirror_api_test.go @@ -107,6 +107,26 @@ func TestMirrorAPICreateJobFromInlineSBOM(t *testing.T) { } } +func TestMirrorAPICreateJobRejectsPURLsAndSBOM(t *testing.T) { + h := setupMirrorAPI(t) + + body, err := json.Marshal(mirror.JobRequest{ + PURLs: []string{"pkg:npm/lodash@4.17.21"}, + SBOM: json.RawMessage(`{"bomFormat":"CycloneDX","components":[]}`), + }) + if err != nil { + t.Fatalf("marshaling request: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/mirror", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.HandleCreate(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + func TestMirrorAPICreateOversizedBody(t *testing.T) { h := setupMirrorAPI(t)