mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 04:04:15 +00:00
An audit of the scan pipeline and the hold side of scanning found several ways scanning stops without saying so. Each fix here was written test-first: a test expressing the wanted behaviour, confirmed failing for the right reason, then the change. A summary-less result crash-looped both processes. worker.go dereferenced result.Summary unconditionally, but processJob only sets it when Grype runs, and SendResult puts the nil on the wire before the scanner dies on it, so handleResult's unguarded log killed the hold too. A nil Summary now means "not scanned for vulnerabilities", deliberately distinct from "scanned, found zero" — inventing a zeroed summary would report every image as clean when Grype never ran. The hold writes a record rather than orphaning the uploaded SBOM, and the appview renders an "SBOM only" state instead of a green Clean badge. The Grype database could wedge with no way back short of a restart. All three throttles in loadVulnDatabase were guarded by vulnDB != nil, so a scanner holding no provider retried a full download on every scan under the exclusive lock. Two earlier attempts at this bug each added one more condition to the same chain; this replaces the chain with a single decision function over a state snapshot, consulted by both call sites so they cannot disagree. That disagreement was itself a bug: the 50-scan reload had never once executed. Two independent halts. An unparseable frame was dropped in silence, stranding a row that held the hold's only dispatch slot forever; it is now answered "skipped" on first delivery. The 10-minute sweep leaked the in-flight digest and wrote no record, permanently retiring one image per timeout. A digest went unvalidated into filepath.Join and os.Create, so a layer digest of sha256:../../../x wrote outside the scan directory, and nothing verified that downloaded bytes hashed to the digest naming them. Digests come from records in a user's own PDS. Both are fixed together: verification is what makes an escaping write self-defeating. Concurrency did not work on either axis. The proactive capacity gate was depth-one hold-wide, so neither extra workers nor extra scanner processes received work. Depth is now the sum of the worker counts scanners advertise on connect, the gate is scoped to proactive work, and dispatch prefers the least-loaded scanner. Disconnects no longer hand a running scan to someone else: a scanner keeps a stable per-process identity and reclaims its own rows within a grace window, while a process that truly restarted returns with a new identity and has its work reclaimed, which is correct because the restart did lose it. The hold's scanning deadline measured queueing rather than scanning, because the scanner acks on receipt and handleAck never refreshed assigned_at. A new "started" message, sent by the worker that dequeues the job, separates the two budgets. An older scanner never sends it and falls under the queueing budget, which is more forgiving than the deadline it gets today. Adds an in-process mock hold and an e2e harness that runs the real client, queue and worker pool, seeded with 84 real manifest records fetched from a live PDS. Real image layouts and the Grype database are fetched by scripts and gitignored; suites needing them skip cleanly, so the default run stays offline and fast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
145 lines
5.0 KiB
Go
145 lines
5.0 KiB
Go
// Package mockhold provides an in-process stand-in for the hold service so
|
|
// scanner tests can drive the real scanner binary through failure scenarios
|
|
// that are impractical to provoke against a live hold: mid-scan disconnects,
|
|
// truncated blobs, stalled downloads, and artifact shapes the real hold's
|
|
// dispatch guards currently refuse to send.
|
|
//
|
|
// The scanner touches exactly three hold endpoints, and this package serves
|
|
// all three:
|
|
//
|
|
// GET /xrpc/io.atcr.hold.subscribeScanJobs WebSocket, jobs out / acks in
|
|
// GET /xrpc/com.atproto.sync.getBlob returns {"url": "..."}
|
|
// GET /blobs/{hex} the bytes that URL points at
|
|
//
|
|
// Blob bytes come from a BlobSource, so the same scenario can run against a
|
|
// real image pulled with skopeo or against synthetic bytes built in the test.
|
|
package mockhold
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ErrBlobNotFound is returned by a BlobSource that has no bytes for a digest.
|
|
// The mock hold turns this into a 404 from getBlob, which is what a real hold
|
|
// returns for a blob that was garbage collected out from under a queued job.
|
|
var ErrBlobNotFound = errors.New("mockhold: blob not found")
|
|
|
|
// BlobSource supplies blob bytes by digest. Implementations must be safe for
|
|
// concurrent use: the scanner downloads a config and every layer in sequence,
|
|
// but several workers may be scanning at once.
|
|
//
|
|
// Size is returned separately from the reader so the mock can set
|
|
// Content-Length. A source that does not know the size ahead of time (a
|
|
// generated stream, say) may return -1, and the mock will use chunked
|
|
// encoding.
|
|
type BlobSource interface {
|
|
Open(digest string) (io.ReadCloser, int64, error)
|
|
}
|
|
|
|
// DigestHex extracts the hex portion of a digest string, mirroring the
|
|
// scanner's own digestHex so a source keyed either way resolves the same.
|
|
func DigestHex(digest string) string {
|
|
if _, hex, ok := strings.Cut(digest, ":"); ok {
|
|
return hex
|
|
}
|
|
return digest
|
|
}
|
|
|
|
// OCILayout serves blobs out of an OCI image layout directory, which is what
|
|
// `skopeo copy docker://<ref> oci:<dir>:<tag>` writes. The layout stores blobs
|
|
// at blobs/sha256/<hex>, keyed by exactly the digest the scan job references,
|
|
// so a layout pulled from a real registry can back a scan with no rewriting.
|
|
type OCILayout struct {
|
|
Dir string
|
|
}
|
|
|
|
// NewOCILayout returns a source reading from an OCI layout directory.
|
|
func NewOCILayout(dir string) *OCILayout { return &OCILayout{Dir: dir} }
|
|
|
|
// Open implements BlobSource.
|
|
func (o *OCILayout) Open(digest string) (io.ReadCloser, int64, error) {
|
|
path := filepath.Join(o.Dir, "blobs", "sha256", DigestHex(digest))
|
|
|
|
// Refuse a digest that escapes the layout. Digests are attacker-controlled
|
|
// in the sense that a test may deliberately feed a malformed one, and a
|
|
// traversal would read outside the fixture rather than failing the way a
|
|
// real hold would.
|
|
if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(o.Dir)+string(os.PathSeparator)) {
|
|
return nil, 0, fmt.Errorf("%w: %q escapes the layout", ErrBlobNotFound, digest)
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, 0, fmt.Errorf("%w: %s", ErrBlobNotFound, digest)
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, 0, err
|
|
}
|
|
|
|
return f, fi.Size(), nil
|
|
}
|
|
|
|
// Memory serves blobs from an in-memory map keyed by digest. Use it for
|
|
// synthetic content: a hand-built tar layer, a gzip bomb, bytes that do not
|
|
// hash to the digest claiming them.
|
|
type Memory struct {
|
|
blobs map[string][]byte
|
|
}
|
|
|
|
// NewMemory returns an empty in-memory source.
|
|
func NewMemory() *Memory { return &Memory{blobs: make(map[string][]byte)} }
|
|
|
|
// Add stores bytes under a digest. The digest is stored by hex, so callers may
|
|
// pass either "sha256:abc" or "abc".
|
|
func (m *Memory) Add(digest string, data []byte) *Memory {
|
|
m.blobs[DigestHex(digest)] = data
|
|
return m
|
|
}
|
|
|
|
// Open implements BlobSource.
|
|
func (m *Memory) Open(digest string) (io.ReadCloser, int64, error) {
|
|
data, ok := m.blobs[DigestHex(digest)]
|
|
if !ok {
|
|
return nil, 0, fmt.Errorf("%w: %s", ErrBlobNotFound, digest)
|
|
}
|
|
return io.NopCloser(bytes.NewReader(data)), int64(len(data)), nil
|
|
}
|
|
|
|
// Multi tries each source in order and returns the first hit. It lets one
|
|
// scenario span several skopeo-pulled layouts, or overlay synthetic bytes on
|
|
// top of a real image to corrupt exactly one layer.
|
|
type Multi struct {
|
|
sources []BlobSource
|
|
}
|
|
|
|
// NewMulti returns a source that consults each of sources in order.
|
|
func NewMulti(sources ...BlobSource) *Multi { return &Multi{sources: sources} }
|
|
|
|
// Open implements BlobSource.
|
|
func (m *Multi) Open(digest string) (io.ReadCloser, int64, error) {
|
|
for _, s := range m.sources {
|
|
rc, n, err := s.Open(digest)
|
|
if err == nil {
|
|
return rc, n, nil
|
|
}
|
|
// A source that failed for a reason other than "I don't have it" is a
|
|
// real fault and must not be masked by a later source happening to.
|
|
if !errors.Is(err, ErrBlobNotFound) {
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
return nil, 0, fmt.Errorf("%w: %s", ErrBlobNotFound, digest)
|
|
}
|