diff --git a/acquire/acquire.go b/acquire/acquire.go new file mode 100644 index 0000000..796fe9f --- /dev/null +++ b/acquire/acquire.go @@ -0,0 +1,435 @@ +// Package acquire coordinates package artifact resolution, downloading, +// integrity verification, and storage. +package acquire + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/git-pkgs/artifacts" + "github.com/git-pkgs/integrity" + "github.com/git-pkgs/purl" + "github.com/opencontainers/go-digest" +) + +const maxInt64 = int64(1<<63 - 1) + +var ( + // ErrNotFound reports that a store has no artifact matching a request. + ErrNotFound = errors.New("artifact not found") + // ErrOffline reports that acquisition would require an upstream request. + ErrOffline = errors.New("artifact unavailable offline") + // ErrTooLarge reports that an artifact exceeds the configured byte limit. + ErrTooLarge = errors.New("artifact exceeds size limit") +) + +// Request identifies one package artifact. Filename and Integrity may narrow +// a version that has several published files. +type Request struct { + PURL string + Filename string + Integrity integrity.SRI +} + +// Source describes the upstream file selected for a request. Integrity is +// used when the request did not supply lockfile integrity metadata. +type Source struct { + URL string + Filename string + MediaType string + Integrity integrity.SRI +} + +// Download is a streaming upstream response. Size is -1 when unavailable. +type Download struct { + Body io.ReadCloser + Size int64 + MediaType string +} + +// Entry is a completed artifact opened from a Store. +type Entry struct { + Artifact artifacts.Artifact + Body io.ReadCloser +} + +// Result is an acquired artifact and a reader positioned at its first byte. +type Result struct { + Artifact artifacts.Artifact + Body io.ReadCloser + Cached bool +} + +// Options controls one acquisition. +type Options struct { + Offline bool + MaxBytes int64 +} + +// Resolver selects an upstream file for a canonical, versioned request. +type Resolver interface { + Resolve(context.Context, Request) (Source, error) +} + +// Fetcher opens an upstream URL. The returned body belongs to the caller. +type Fetcher interface { + Fetch(context.Context, string) (*Download, error) +} + +// Store finds completed artifacts and creates private staging writes. +// +// Open must return ErrNotFound when no completed artifact matches every +// populated Request field. A Stage must remain unavailable through Open until +// Commit succeeds. +type Store interface { + Open(context.Context, Request) (*Entry, error) + Stage(context.Context, Request, Source) (Stage, error) +} + +// Stage receives unverified bytes. Commit publishes them and returns a fresh +// reader. Discard removes an unpublished stage. +type Stage interface { + io.Writer + Commit(context.Context, artifacts.Artifact) (io.ReadCloser, error) + Discard(context.Context) error +} + +// Service acquires package artifacts through supplied resolver, fetcher, and +// store implementations. +type Service struct { + Resolver Resolver + Fetcher Fetcher + Store Store +} + +// Acquire returns a matching stored artifact or resolves and fetches one. +func (service Service) Acquire(ctx context.Context, request Request, options Options) (*Result, error) { + request, err := normalizeRequest(request) + if err != nil { + return nil, err + } + if err := validateOptions(options); err != nil { + return nil, err + } + + entry, err := service.open(ctx, request) + if err != nil { + return nil, err + } + if entry != nil { + return resultFromEntry(entry, true), nil + } + if options.Offline { + return nil, fmt.Errorf("%w: %s", ErrOffline, request.PURL) + } + if service.Resolver == nil { + return nil, fmt.Errorf("acquire artifact: nil resolver") + } + + source, err := service.Resolver.Resolve(ctx, request) + if err != nil { + return nil, fmt.Errorf("resolve artifact %s: %w", request.PURL, err) + } + return service.acquireSource(ctx, request, source, options, true) +} + +// AcquireFrom returns a matching stored artifact or fetches the supplied +// source. It supports callers that already obtained an exact artifact URL. +func (service Service) AcquireFrom(ctx context.Context, request Request, source Source, options Options) (*Result, error) { + request, err := normalizeRequest(request) + if err != nil { + return nil, err + } + if err := validateOptions(options); err != nil { + return nil, err + } + return service.acquireSource(ctx, request, source, options, false) +} + +func (service Service) acquireSource( + ctx context.Context, + request Request, + source Source, + options Options, + requestAlreadyMissed bool, +) (*Result, error) { + source, err := normalizeSource(source) + if err != nil { + return nil, err + } + lookup, err := requestForSource(request, source) + if err != nil { + return nil, err + } + entry, err := service.openSource(ctx, request, lookup, requestAlreadyMissed) + if err != nil { + return nil, err + } + if entry != nil { + return resultFromEntry(entry, true), nil + } + if options.Offline { + return nil, fmt.Errorf("%w: %s", ErrOffline, request.PURL) + } + if service.Fetcher == nil { + return nil, fmt.Errorf("acquire artifact: nil fetcher") + } + + download, err := service.Fetcher.Fetch(ctx, source.URL) + if err != nil { + return nil, fmt.Errorf("fetch artifact %s: %w", request.PURL, err) + } + if err := validateDownload(request.PURL, download, options); err != nil { + return nil, err + } + defer func() { _ = download.Body.Close() }() + return service.stageDownload(ctx, request, lookup, source, download, options) +} + +func (service Service) stageDownload( + ctx context.Context, + request Request, + lookup Request, + source Source, + download *Download, + options Options, +) (*Result, error) { + stage, err := service.Store.Stage(ctx, lookup, source) + if err != nil { + return nil, fmt.Errorf("stage artifact %s: %w", request.PURL, err) + } + if stage == nil { + return nil, fmt.Errorf("stage artifact %s: nil stage", request.PURL) + } + committed := false + defer func() { + if !committed { + _ = stage.Discard(context.WithoutCancel(ctx)) + } + }() + + hashed, err := copyAndVerify(stage, download.Body, lookup.Integrity, options.MaxBytes) + if err != nil { + return nil, fmt.Errorf("verify artifact %s: %w", request.PURL, err) + } + sha256Digest, err := resultDigest(hashed, integrity.SHA256) + if err != nil { + return nil, fmt.Errorf("hash artifact %s: %w", request.PURL, err) + } + + mediaType := source.MediaType + if download.MediaType != "" { + mediaType = download.MediaType + } + artifact, err := artifacts.New( + request.PURL, + digest.Digest("sha256:"+sha256Digest.Hex()), + hashed.Bytes, + lookup.Filename, + mediaType, + ) + if err != nil { + return nil, fmt.Errorf("describe artifact %s: %w", request.PURL, err) + } + + body, err := stage.Commit(ctx, artifact) + if err != nil { + return nil, fmt.Errorf("commit artifact %s: %w", request.PURL, err) + } + if body == nil { + return nil, fmt.Errorf("commit artifact %s: nil body", request.PURL) + } + committed = true + return &Result{Artifact: artifact, Body: body}, nil +} + +func (service Service) openSource( + ctx context.Context, + request Request, + lookup Request, + requestAlreadyMissed bool, +) (*Entry, error) { + if requestAlreadyMissed && sameRequest(request, lookup) { + return nil, nil + } + return service.open(ctx, lookup) +} + +func validateDownload(packageURL string, download *Download, options Options) error { + if download == nil { + return fmt.Errorf("fetch artifact %s: nil download", packageURL) + } + if download.Body == nil { + return fmt.Errorf("fetch artifact %s: nil body", packageURL) + } + if download.Size < -1 { + _ = download.Body.Close() + return fmt.Errorf("fetch artifact %s: invalid size %d", packageURL, download.Size) + } + if options.MaxBytes > 0 && download.Size > options.MaxBytes { + _ = download.Body.Close() + return fmt.Errorf("%w: declared %d bytes, limit %d", ErrTooLarge, download.Size, options.MaxBytes) + } + return nil +} + +func copyAndVerify( + destination io.Writer, + source io.Reader, + expected integrity.SRI, + maxBytes int64, +) (integrity.Result, error) { + reader, err := integrity.NewReader(source, digestAlgorithms(expected)...) + if err != nil { + return integrity.Result{}, err + } + content := io.Reader(reader) + if maxBytes > 0 && maxBytes < maxInt64 { + content = io.LimitReader(reader, maxBytes+1) + } + written, err := io.Copy(destination, content) + if err != nil { + return integrity.Result{}, err + } + if maxBytes > 0 && written > maxBytes { + return integrity.Result{}, fmt.Errorf("%w: read more than %d bytes", ErrTooLarge, maxBytes) + } + + result := reader.Result() + if !result.Complete { + return integrity.Result{}, fmt.Errorf("incomplete stream") + } + if len(expected) > 0 { + if err := result.Verify(expected); err != nil { + return integrity.Result{}, err + } + } + return result, nil +} + +func (service Service) open(ctx context.Context, request Request) (*Entry, error) { + if service.Store == nil { + return nil, fmt.Errorf("acquire artifact: nil store") + } + entry, err := service.Store.Open(ctx, request) + if errors.Is(err, ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("open artifact %s: %w", request.PURL, err) + } + if entry == nil { + return nil, fmt.Errorf("open artifact %s: nil entry", request.PURL) + } + if entry.Body == nil { + return nil, fmt.Errorf("open artifact %s: nil body", request.PURL) + } + if err := entry.Artifact.Validate(); err != nil { + _ = entry.Body.Close() + return nil, fmt.Errorf("open artifact %s: invalid metadata: %w", request.PURL, err) + } + if entry.Artifact.PURL != request.PURL { + _ = entry.Body.Close() + return nil, fmt.Errorf("open artifact %s: store returned PURL %s", request.PURL, entry.Artifact.PURL) + } + if request.Filename != "" && entry.Artifact.Filename != request.Filename { + _ = entry.Body.Close() + return nil, fmt.Errorf("open artifact %s: store returned filename %q", request.PURL, entry.Artifact.Filename) + } + return entry, nil +} + +func normalizeRequest(request Request) (Request, error) { + parsed, err := purl.Parse(request.PURL) + if err != nil { + return Request{}, fmt.Errorf("acquire artifact: PURL: %w", err) + } + if parsed.Version == "" { + return Request{}, fmt.Errorf("acquire artifact: PURL has no version") + } + if parsed.Subpath != "" { + return Request{}, fmt.Errorf("acquire artifact: PURL has a subpath") + } + if err := validateIntegrity(request.Integrity); err != nil { + return Request{}, fmt.Errorf("acquire artifact: integrity: %w", err) + } + request.PURL = parsed.String() + return request, nil +} + +func normalizeSource(source Source) (Source, error) { + if source.URL == "" { + return Source{}, fmt.Errorf("acquire artifact: source URL is empty") + } + if err := validateIntegrity(source.Integrity); err != nil { + return Source{}, fmt.Errorf("acquire artifact: source integrity: %w", err) + } + return source, nil +} + +func validateIntegrity(metadata integrity.SRI) error { + for index, item := range metadata { + if _, err := integrity.ParseHex(item.Algorithm(), item.Hex()); err != nil { + return fmt.Errorf("entry %d: %w", index+1, err) + } + } + return nil +} + +func validateOptions(options Options) error { + if options.MaxBytes < 0 { + return fmt.Errorf("acquire artifact: max bytes must be zero or greater") + } + return nil +} + +func requestForSource(request Request, source Source) (Request, error) { + if request.Filename != "" && source.Filename != "" && request.Filename != source.Filename { + return Request{}, fmt.Errorf( + "acquire artifact: source filename %q does not match request filename %q", + source.Filename, + request.Filename, + ) + } + if request.Filename == "" { + request.Filename = source.Filename + } + if len(request.Integrity) == 0 { + request.Integrity = source.Integrity + } + return request, nil +} + +func sameRequest(first, second Request) bool { + return first.PURL == second.PURL && + first.Filename == second.Filename && + integrity.FormatSRI(first.Integrity) == integrity.FormatSRI(second.Integrity) +} + +func digestAlgorithms(expected integrity.SRI) []integrity.Algorithm { + algorithms := make([]integrity.Algorithm, 0, len(expected)+1) + algorithms = append(algorithms, integrity.SHA256) + for _, item := range expected { + algorithms = append(algorithms, item.Algorithm()) + } + return algorithms +} + +func resultDigest(result integrity.Result, algorithm integrity.Algorithm) (integrity.Digest, error) { + for _, item := range result.Digests { + if item.Algorithm() == algorithm { + return item, nil + } + } + return integrity.Digest{}, fmt.Errorf("no %s digest", algorithm) +} + +func resultFromEntry(entry *Entry, cached bool) *Result { + return &Result{ + Artifact: entry.Artifact, + Body: entry.Body, + Cached: cached, + } +} diff --git a/acquire/acquire_test.go b/acquire/acquire_test.go new file mode 100644 index 0000000..b79c061 --- /dev/null +++ b/acquire/acquire_test.go @@ -0,0 +1,548 @@ +package acquire + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/sha512" + "errors" + "fmt" + "io" + "strings" + "testing" + + "github.com/git-pkgs/artifacts" + "github.com/git-pkgs/integrity" + "github.com/opencontainers/go-digest" +) + +const testPURL = "pkg:npm/example@1.0.0" + +func TestAcquireReturnsStoredArtifact(t *testing.T) { + content := []byte("stored package") + stored := testArtifact(t, testPURL, "example.tgz", content) + store := &testStore{ + open: func(_ context.Context, request Request) (*Entry, error) { + if request.PURL != testPURL { + t.Errorf("Open PURL = %q, want %q", request.PURL, testPURL) + } + return &Entry{Artifact: stored, Body: io.NopCloser(bytes.NewReader(content))}, nil + }, + } + resolver := &testResolver{} + fetcher := &testFetcher{} + service := Service{Resolver: resolver, Fetcher: fetcher, Store: store} + + result, err := service.Acquire(context.Background(), Request{PURL: "pkg:NPM/example@1.0.0"}, Options{}) + if err != nil { + t.Fatalf("Acquire() error = %v", err) + } + defer func() { _ = result.Body.Close() }() + if !result.Cached { + t.Error("Cached = false, want true") + } + if result.Artifact != stored { + t.Errorf("Artifact = %+v, want %+v", result.Artifact, stored) + } + if got := readString(t, result.Body); got != string(content) { + t.Errorf("Body = %q, want %q", got, content) + } + if resolver.calls != 0 || fetcher.calls != 0 { + t.Errorf("resolver calls = %d, fetcher calls = %d, want zero", resolver.calls, fetcher.calls) + } +} + +func TestAcquireOfflineMiss(t *testing.T) { + store := missingStore() + resolver := &testResolver{} + service := Service{Resolver: resolver, Store: store} + + _, err := service.Acquire(context.Background(), Request{PURL: testPURL}, Options{Offline: true}) + if !errors.Is(err, ErrOffline) { + t.Fatalf("Acquire() error = %v, want ErrOffline", err) + } + if resolver.calls != 0 { + t.Errorf("resolver calls = %d, want zero", resolver.calls) + } +} + +func TestAcquireResolvesVerifiesAndCommits(t *testing.T) { + content := []byte("downloaded package") + requestIntegrity := testSRI(t, integrity.SHA512, content) + wrongSourceIntegrity := testSRI(t, integrity.SHA256, []byte("different package")) + source := Source{ + URL: "https://registry.example/example.tgz", + Filename: "example.tgz", + MediaType: "application/gzip", + Integrity: wrongSourceIntegrity, + } + resolver := &testResolver{source: source} + body := &trackingBody{Reader: bytes.NewReader(content)} + fetcher := &testFetcher{download: &Download{ + Body: body, + Size: int64(len(content)), + MediaType: "application/x-gzip", + }} + store := missingStore() + service := Service{Resolver: resolver, Fetcher: fetcher, Store: store} + + result, err := service.Acquire(context.Background(), Request{ + PURL: testPURL, + Integrity: requestIntegrity, + }, Options{}) + if err != nil { + t.Fatalf("Acquire() error = %v", err) + } + defer func() { _ = result.Body.Close() }() + if result.Cached { + t.Error("Cached = true, want false") + } + if !body.closed { + t.Error("download body was not closed") + } + if result.Artifact.PURL != testPURL { + t.Errorf("PURL = %q, want %q", result.Artifact.PURL, testPURL) + } + if result.Artifact.Filename != source.Filename { + t.Errorf("Filename = %q, want %q", result.Artifact.Filename, source.Filename) + } + if result.Artifact.MediaType != fetcher.download.MediaType { + t.Errorf("MediaType = %q, want %q", result.Artifact.MediaType, fetcher.download.MediaType) + } + if result.Artifact.Size != int64(len(content)) { + t.Errorf("Size = %d, want %d", result.Artifact.Size, len(content)) + } + wantDigest := sha256.Sum256(content) + if result.Artifact.Digest != digest.Digest(fmt.Sprintf("sha256:%x", wantDigest)) { + t.Errorf("Digest = %q, want sha256:%x", result.Artifact.Digest, wantDigest) + } + if got := readString(t, result.Body); got != string(content) { + t.Errorf("Body = %q, want %q", got, content) + } + if store.stage == nil || !store.stage.committed { + t.Fatal("stage was not committed") + } + if store.stage.discarded { + t.Error("committed stage was discarded") + } + if got := store.stage.buffer.String(); got != string(content) { + t.Errorf("staged bytes = %q, want %q", got, content) + } + if got := store.stage.request.Integrity; formatSRI(got) != formatSRI(requestIntegrity) { + t.Errorf("staged integrity = %q, want %q", formatSRI(got), formatSRI(requestIntegrity)) + } + if resolver.request.PURL != testPURL { + t.Errorf("resolver PURL = %q, want %q", resolver.request.PURL, testPURL) + } +} + +func TestAcquireChecksResolvedStoreKeyBeforeFetching(t *testing.T) { + content := []byte("stored resolved package") + stored := testArtifact(t, testPURL, "selected.whl", content) + store := &testStore{ + open: func(_ context.Context, request Request) (*Entry, error) { + if request.Filename == "" { + return nil, ErrNotFound + } + if request.Filename != stored.Filename { + t.Errorf("filename = %q, want %q", request.Filename, stored.Filename) + } + return &Entry{Artifact: stored, Body: io.NopCloser(bytes.NewReader(content))}, nil + }, + } + resolver := &testResolver{source: Source{ + URL: "https://files.example/selected.whl", + Filename: stored.Filename, + }} + fetcher := &testFetcher{} + service := Service{Resolver: resolver, Fetcher: fetcher, Store: store} + + result, err := service.Acquire(context.Background(), Request{PURL: testPURL}, Options{}) + if err != nil { + t.Fatalf("Acquire() error = %v", err) + } + defer func() { _ = result.Body.Close() }() + if !result.Cached { + t.Error("Cached = false, want true") + } + if store.openCalls != 2 { + t.Errorf("Open calls = %d, want 2", store.openCalls) + } + if fetcher.calls != 0 { + t.Errorf("Fetch calls = %d, want zero", fetcher.calls) + } +} + +func TestAcquireFromUsesExactSourceWithoutResolver(t *testing.T) { + content := []byte("exact source") + store := missingStore() + fetcher := &testFetcher{download: &Download{ + Body: io.NopCloser(bytes.NewReader(content)), + Size: -1, + }} + resolver := &testResolver{err: errors.New("resolver must not be called")} + service := Service{Resolver: resolver, Fetcher: fetcher, Store: store} + + result, err := service.AcquireFrom(context.Background(), Request{PURL: testPURL}, Source{ + URL: "https://files.example/exact.tgz", + Filename: "exact.tgz", + }, Options{}) + if err != nil { + t.Fatalf("AcquireFrom() error = %v", err) + } + defer func() { _ = result.Body.Close() }() + if resolver.calls != 0 { + t.Errorf("resolver calls = %d, want zero", resolver.calls) + } + if result.Artifact.Filename != "exact.tgz" { + t.Errorf("Filename = %q, want exact.tgz", result.Artifact.Filename) + } +} + +func TestAcquireUsesSourceIntegrity(t *testing.T) { + content := []byte("downloaded package") + store := missingStore() + service := Service{ + Resolver: &testResolver{source: Source{ + URL: "https://registry.example/example.tgz", + Filename: "example.tgz", + Integrity: testSRI(t, integrity.SHA512, []byte("different package")), + }}, + Fetcher: &testFetcher{download: &Download{ + Body: io.NopCloser(bytes.NewReader(content)), + Size: int64(len(content)), + }}, + Store: store, + } + + _, err := service.Acquire(context.Background(), Request{PURL: testPURL}, Options{}) + if err == nil || !strings.Contains(err.Error(), "integrity mismatch") { + t.Fatalf("Acquire() error = %v, want integrity mismatch", err) + } + if store.stage == nil || !store.stage.discarded { + t.Error("mismatched stage was not discarded") + } + if store.stage.committed { + t.Error("mismatched stage was committed") + } +} + +func TestAcquireRejectsConflictingSourceFilename(t *testing.T) { + store := missingStore() + fetcher := &testFetcher{} + service := Service{ + Resolver: &testResolver{source: Source{ + URL: "https://registry.example/other.tgz", + Filename: "other.tgz", + }}, + Fetcher: fetcher, + Store: store, + } + + _, err := service.Acquire(context.Background(), Request{ + PURL: testPURL, + Filename: "expected.tgz", + }, Options{}) + if err == nil || !strings.Contains(err.Error(), "source filename") { + t.Fatalf("Acquire() error = %v, want filename mismatch", err) + } + if fetcher.calls != 0 { + t.Errorf("Fetch calls = %d, want zero", fetcher.calls) + } +} + +func TestAcquireRejectsDeclaredOversizeBeforeStaging(t *testing.T) { + body := &trackingBody{Reader: strings.NewReader("large")} + store := missingStore() + service := Service{ + Resolver: &testResolver{source: Source{URL: "https://registry.example/large.tgz"}}, + Fetcher: &testFetcher{download: &Download{Body: body, Size: 100}}, + Store: store, + } + + _, err := service.Acquire(context.Background(), Request{PURL: testPURL}, Options{MaxBytes: 10}) + if !errors.Is(err, ErrTooLarge) { + t.Fatalf("Acquire() error = %v, want ErrTooLarge", err) + } + if store.stageCalls != 0 { + t.Errorf("Stage calls = %d, want zero", store.stageCalls) + } + if !body.closed { + t.Error("oversize download body was not closed") + } +} + +func TestAcquireClosesBodyWithInvalidDeclaredSize(t *testing.T) { + body := &trackingBody{Reader: strings.NewReader("package")} + store := missingStore() + service := Service{ + Resolver: &testResolver{source: Source{URL: "https://registry.example/package.tgz"}}, + Fetcher: &testFetcher{download: &Download{Body: body, Size: -2}}, + Store: store, + } + + _, err := service.Acquire(context.Background(), Request{PURL: testPURL}, Options{}) + if err == nil || !strings.Contains(err.Error(), "invalid size") { + t.Fatalf("Acquire() error = %v, want invalid size", err) + } + if !body.closed { + t.Error("invalid download body was not closed") + } + if store.stageCalls != 0 { + t.Errorf("Stage calls = %d, want zero", store.stageCalls) + } +} + +func TestAcquireRejectsStreamingOversizeAndDiscards(t *testing.T) { + content := []byte("more than five bytes") + store := missingStore() + service := Service{ + Resolver: &testResolver{source: Source{URL: "https://registry.example/large.tgz"}}, + Fetcher: &testFetcher{download: &Download{ + Body: io.NopCloser(bytes.NewReader(content)), + Size: -1, + }}, + Store: store, + } + + _, err := service.Acquire(context.Background(), Request{PURL: testPURL}, Options{MaxBytes: 5}) + if !errors.Is(err, ErrTooLarge) { + t.Fatalf("Acquire() error = %v, want ErrTooLarge", err) + } + if store.stage == nil || !store.stage.discarded { + t.Error("oversize stage was not discarded") + } + if store.stage.buffer.Len() != 6 { + t.Errorf("staged bytes = %d, want limit plus one", store.stage.buffer.Len()) + } +} + +func TestAcquireAllowsMaximumSizeLimit(t *testing.T) { + content := []byte("package") + store := missingStore() + service := Service{ + Resolver: &testResolver{source: Source{URL: "https://registry.example/package.tgz"}}, + Fetcher: &testFetcher{download: &Download{ + Body: io.NopCloser(bytes.NewReader(content)), + Size: int64(len(content)), + }}, + Store: store, + } + + result, err := service.Acquire(context.Background(), Request{PURL: testPURL}, Options{MaxBytes: maxInt64}) + if err != nil { + t.Fatal(err) + } + _ = result.Body.Close() +} + +func TestAcquireDiscardsWhenCommitFails(t *testing.T) { + store := missingStore() + store.commitErr = errors.New("store unavailable") + service := Service{ + Resolver: &testResolver{source: Source{URL: "https://registry.example/example.tgz"}}, + Fetcher: &testFetcher{download: &Download{ + Body: io.NopCloser(strings.NewReader("package")), + Size: -1, + }}, + Store: store, + } + + _, err := service.Acquire(context.Background(), Request{PURL: testPURL}, Options{}) + if err == nil || !strings.Contains(err.Error(), "commit artifact") { + t.Fatalf("Acquire() error = %v, want commit context", err) + } + if store.stage == nil || !store.stage.discarded { + t.Error("failed commit stage was not discarded") + } +} + +func TestAcquireRejectsInvalidInputs(t *testing.T) { + invalidIntegrity := integrity.SRI{{}} + tests := []struct { + name string + request Request + options Options + want string + }{ + {name: "invalid PURL", request: Request{PURL: "not-a-purl"}, want: "PURL"}, + {name: "unversioned PURL", request: Request{PURL: "pkg:npm/example"}, want: "no version"}, + {name: "PURL subpath", request: Request{PURL: testPURL + "#package.json"}, want: "subpath"}, + {name: "invalid integrity", request: Request{PURL: testPURL, Integrity: invalidIntegrity}, want: "integrity"}, + {name: "negative max bytes", request: Request{PURL: testPURL}, options: Options{MaxBytes: -1}, want: "max bytes"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := (Service{}).Acquire(context.Background(), test.request, test.options) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Acquire() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestAcquireClosesInvalidStoredEntry(t *testing.T) { + body := &trackingBody{Reader: strings.NewReader("bad entry")} + store := &testStore{ + open: func(context.Context, Request) (*Entry, error) { + return &Entry{Artifact: artifacts.Artifact{}, Body: body}, nil + }, + } + + _, err := (Service{Store: store}).Acquire(context.Background(), Request{PURL: testPURL}, Options{}) + if err == nil || !strings.Contains(err.Error(), "invalid metadata") { + t.Fatalf("Acquire() error = %v, want invalid metadata", err) + } + if !body.closed { + t.Error("invalid stored body was not closed") + } +} + +type testResolver struct { + source Source + err error + calls int + request Request +} + +func (resolver *testResolver) Resolve(_ context.Context, request Request) (Source, error) { + resolver.calls++ + resolver.request = request + return resolver.source, resolver.err +} + +type testFetcher struct { + download *Download + err error + calls int + url string +} + +func (fetcher *testFetcher) Fetch(_ context.Context, url string) (*Download, error) { + fetcher.calls++ + fetcher.url = url + return fetcher.download, fetcher.err +} + +type testStore struct { + open func(context.Context, Request) (*Entry, error) + openCalls int + stageCalls int + stage *testStage + stageErr error + commitErr error +} + +func (store *testStore) Open(ctx context.Context, request Request) (*Entry, error) { + store.openCalls++ + return store.open(ctx, request) +} + +//nolint:ireturn // Store requires the Stage interface so tests exercise the public boundary. +func (store *testStore) Stage(_ context.Context, request Request, source Source) (Stage, error) { + store.stageCalls++ + if store.stageErr != nil { + return nil, store.stageErr + } + store.stage = &testStage{request: request, source: source, commitErr: store.commitErr} + return store.stage, nil +} + +type testStage struct { + request Request + source Source + buffer bytes.Buffer + artifact artifacts.Artifact + commitErr error + committed bool + discarded bool +} + +func (stage *testStage) Write(content []byte) (int, error) { + return stage.buffer.Write(content) +} + +func (stage *testStage) Commit(_ context.Context, artifact artifacts.Artifact) (io.ReadCloser, error) { + stage.artifact = artifact + if stage.commitErr != nil { + return nil, stage.commitErr + } + stage.committed = true + return io.NopCloser(bytes.NewReader(stage.buffer.Bytes())), nil +} + +func (stage *testStage) Discard(context.Context) error { + stage.discarded = true + return nil +} + +type trackingBody struct { + io.Reader + closed bool +} + +func (body *trackingBody) Close() error { + body.closed = true + return nil +} + +func missingStore() *testStore { + return &testStore{ + open: func(context.Context, Request) (*Entry, error) { + return nil, ErrNotFound + }, + } +} + +func testArtifact(t *testing.T, packageURL, filename string, content []byte) artifacts.Artifact { + t.Helper() + sum := sha256.Sum256(content) + artifact, err := artifacts.New( + packageURL, + digest.Digest(fmt.Sprintf("sha256:%x", sum)), + int64(len(content)), + filename, + "application/octet-stream", + ) + if err != nil { + t.Fatal(err) + } + return artifact +} + +func testSRI(t *testing.T, algorithm integrity.Algorithm, content []byte) integrity.SRI { + t.Helper() + var encoded string + switch algorithm { + case integrity.SHA256: + sum := sha256.Sum256(content) + encoded = fmt.Sprintf("%x", sum) + case integrity.SHA384: + sum := sha512.Sum384(content) + encoded = fmt.Sprintf("%x", sum) + case integrity.SHA512: + sum := sha512.Sum512(content) + encoded = fmt.Sprintf("%x", sum) + default: + t.Fatalf("unsupported test algorithm %s", algorithm) + } + digest, err := integrity.ParseHex(algorithm, encoded) + if err != nil { + t.Fatal(err) + } + return integrity.SRI{digest} +} + +func formatSRI(metadata integrity.SRI) string { + return integrity.FormatSRI(metadata) +} + +func readString(t *testing.T, reader io.Reader) string { + t.Helper() + content, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + return string(content) +} diff --git a/go.mod b/go.mod index 0c647c0..7c9688d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/git-pkgs/artifacts go 1.25.6 require ( + github.com/git-pkgs/integrity v0.1.1 github.com/git-pkgs/purl v0.1.16 github.com/opencontainers/go-digest v1.0.0 ) diff --git a/go.sum b/go.sum index effd74b..2d20380 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/git-pkgs/integrity v0.1.1 h1:nHQ7SktOiGM1dOb5BFnkdtttG/6FCgE6r5ru6QnsGts= +github.com/git-pkgs/integrity v0.1.1/go.mod h1:hxu24lcd230377hCF28JQW7sGcCbuNLqo/0ULeb+F1Q= github.com/git-pkgs/purl v0.1.16 h1:VAX6tv0hhdTENbkrGMoPZbOAl1Y8U1/ZnzoCsYuNBYM= github.com/git-pkgs/purl v0.1.16/go.mod h1:7u7ora8tQdrkS7Auclr5v8dCJdjN4ej6AbrvYZi2b7k= github.com/git-pkgs/vers v0.3.1 h1:jy/ht2wIRJI5zQrccm6GTeYr+hGFwe2z8LV1HOr4Wco=