Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |

Expand Down
7 changes: 6 additions & 1 deletion cmd/proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, Name: *sbomPath}
case len(purls) > 0:
source = &mirror.PURLSource{PURLs: purls}
default:
Expand Down
8 changes: 8 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
12 changes: 9 additions & 3 deletions internal/mirror/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mirror
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"sync"
"time"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -190,12 +192,16 @@ 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:
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")
}
}

Expand Down
32 changes: 32 additions & 0 deletions internal/mirror/job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mirror

import (
"context"
"encoding/json"
"testing"
"time"
)
Expand Down Expand Up @@ -100,6 +101,37 @@ 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 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)
Expand Down
22 changes: 10 additions & 12 deletions internal/mirror/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"os"

cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/git-pkgs/purl"
Expand Down Expand Up @@ -94,40 +93,39 @@ 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
Name string
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)
if s.Name != "" {
return fmt.Errorf("parsing SBOM %s: %w", s.Name, err)
}
return fmt.Errorf("parsing SBOM: %w", err)
}
Comment thread
abhinavgautam01 marked this conversation as resolved.

inner := &PURLSource{PURLs: purls, RegClient: s.RegClient}
return inner.Enumerate(ctx, fn)
}

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
}

Expand Down
37 changes: 13 additions & 24 deletions internal/mirror/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ package mirror
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -116,8 +115,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 {
Expand Down Expand Up @@ -164,8 +162,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 {
Expand All @@ -185,42 +182,38 @@ 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"), 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) {
bom := map[string]any{
"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
})
Expand All @@ -229,15 +222,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
}
56 changes: 56 additions & 0 deletions internal/server/mirror_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,62 @@ 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 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)

Expand Down