Files

313 lines
8.7 KiB
Go

package store
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"sync"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"golang.org/x/sync/errgroup"
)
// BlobStatus describes the outcome of checking a single blob against its descriptor.
type BlobStatus string
const (
BlobOK BlobStatus = "ok"
BlobMissing BlobStatus = "missing"
BlobSizeMismatch BlobStatus = "size-mismatch"
BlobDigestMismatch BlobStatus = "digest-mismatch"
BlobUnreadable BlobStatus = "unreadable"
)
// BlobResult is the outcome for one blob. Detail is human-readable context
// (e.g. "expected 4194304 bytes, found 1050624").
type BlobResult struct {
Digest string
Status BlobStatus
Detail string
}
// CheckResult aggregates a descriptor graph. Problems is empty when OK.
type CheckResult struct {
OK bool
Problems []BlobResult
}
// checkEntry memoizes the check result for a single digest so that a blob
// shared across many artifacts (e.g. a common base layer) is only hashed once per
// Checker, no matter how many goroutines request it concurrently: sync.Once blocks
// concurrent callers until the first caller's CheckBlob call has completed.
type checkEntry struct {
once sync.Once
result BlobResult
}
// Checker recomputes and checks the on-disk content of blobs referenced by a
// Layout's index, detecting blobs left truncated or corrupted by an interrupted
// pull. A Checker is intended for the lifetime of a single command invocation:
// it memoizes results per-digest so repeated references to the same blob (e.g. a
// shared base layer across multiple images) are only hashed once.
type Checker struct {
l *Layout
mu sync.Mutex
memo map[string]*checkEntry
// hashMu/hashCounts track, per-digest, how many times CheckBlob actually
// streamed a blob's content through a digest verifier (as opposed to
// short-circuiting on a missing-file or size-mismatch check). This has no
// effect on check results; it exists so tests can prove that
// size-mismatch short-circuiting and per-digest memoization avoid redundant
// hashing of large blobs.
hashMu sync.Mutex
hashCounts map[string]int
}
// NewChecker returns a Checker bound to l.
func (l *Layout) NewChecker() *Checker {
return &Checker{
l: l,
memo: make(map[string]*checkEntry),
hashCounts: make(map[string]int),
}
}
// HashCount returns the number of times the blob identified by digestStr (e.g.
// "sha256:abc...") was actually streamed through a digest verifier.
func (c *Checker) HashCount(digestStr string) int {
c.hashMu.Lock()
defer c.hashMu.Unlock()
return c.hashCounts[digestStr]
}
func (c *Checker) recordHash(digestStr string) {
c.hashMu.Lock()
c.hashCounts[digestStr]++
c.hashMu.Unlock()
}
// CheckBlob checks exactly one blob on disk against its descriptor. It does not
// decode the blob or recurse into anything it references.
//
// Checks run in this order, each short-circuiting the next:
// 1. The blob file must exist at its content-addressed path under
// <root>/blobs/<algorithm>/<hex>.
// 2. If desc.Size is known (> 0), the file size on disk must match exactly --
// a mismatch here is reported without ever reading the file's content.
// 3. The file's content is streamed (never fully buffered in memory) through
// the descriptor's digest verifier.
func (c *Checker) CheckBlob(desc ocispec.Descriptor) BlobResult {
digestStr := desc.Digest.String()
blobFile := filepath.Join(c.l.Root, ocispec.ImageBlobsDir, desc.Digest.Algorithm().String(), desc.Digest.Hex())
info, err := os.Stat(blobFile)
if err != nil {
if os.IsNotExist(err) {
return BlobResult{
Digest: digestStr,
Status: BlobMissing,
Detail: fmt.Sprintf("blob not found at %s", blobFile),
}
}
return BlobResult{
Digest: digestStr,
Status: BlobUnreadable,
Detail: err.Error(),
}
}
if desc.Size > 0 && info.Size() != desc.Size {
return BlobResult{
Digest: digestStr,
Status: BlobSizeMismatch,
Detail: fmt.Sprintf("expected %d bytes, found %d", desc.Size, info.Size()),
}
}
if !desc.Digest.Algorithm().Available() {
return BlobResult{
Digest: digestStr,
Status: BlobUnreadable,
Detail: fmt.Sprintf("digest algorithm %q is not available in this build", desc.Digest.Algorithm()),
}
}
f, err := os.Open(blobFile)
if err != nil {
return BlobResult{
Digest: digestStr,
Status: BlobUnreadable,
Detail: err.Error(),
}
}
defer f.Close()
// dv (go-digest's Verifier) is the hash-comparison API from
// github.com/opencontainers/go-digest, not this package's Checker type.
dv := desc.Digest.Verifier()
c.recordHash(digestStr)
if _, err := io.Copy(dv, f); err != nil {
return BlobResult{
Digest: digestStr,
Status: BlobUnreadable,
Detail: fmt.Sprintf("reading blob content: %v", err),
}
}
if !dv.Verified() {
return BlobResult{
Digest: digestStr,
Status: BlobDigestMismatch,
Detail: "content does not match its digest",
}
}
return BlobResult{Digest: digestStr, Status: BlobOK}
}
// checkMemo returns the memoized CheckBlob result for desc.Digest, computing it
// at most once per Checker lifetime even when called concurrently for the same
// digest from multiple goroutines.
func (c *Checker) checkMemo(desc ocispec.Descriptor) BlobResult {
key := desc.Digest.String()
c.mu.Lock()
entry, ok := c.memo[key]
if !ok {
entry = &checkEntry{}
c.memo[key] = entry
}
c.mu.Unlock()
entry.once.Do(func() {
entry.result = c.CheckBlob(desc)
})
return entry.result
}
// manifestLike captures the fields common to both an OCI image manifest and an OCI
// image index, letting Check walk either shape uniformly -- a sibling of the
// anonymous decode struct used by Layout.CleanUp, but performing a real byte
// check instead of only marking digests as referenced.
type manifestLike struct {
Config ocispec.Descriptor `json:"config"`
Layers []ocispec.Descriptor `json:"layers"`
Manifests []ocispec.Descriptor `json:"manifests"`
}
// Check walks the descriptor graph rooted at desc -- the manifest blob itself,
// its config and layers, and (for an image index) each child manifest recursively
// -- and aggregates every blob that fails its check.
//
// If the manifest blob itself fails its check, Check stops descending: a
// manifest whose own bytes don't match its digest cannot be trusted to accurately
// name its children, so attempting to check those children would be meaningless.
// A manifest that passes its own digest check but fails to decode as JSON is
// reported as BlobUnreadable (the bytes are correct per-digest, but malformed).
func (c *Checker) Check(ctx context.Context, desc ocispec.Descriptor) CheckResult {
result := CheckResult{OK: true}
manifestResult := c.checkMemo(desc)
if manifestResult.Status != BlobOK {
result.OK = false
result.Problems = append(result.Problems, manifestResult)
return result
}
rc, err := c.l.OCI.Fetch(ctx, desc)
if err != nil {
result.OK = false
result.Problems = append(result.Problems, BlobResult{
Digest: desc.Digest.String(),
Status: BlobUnreadable,
Detail: fmt.Sprintf("fetching manifest: %v", err),
})
return result
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
result.OK = false
result.Problems = append(result.Problems, BlobResult{
Digest: desc.Digest.String(),
Status: BlobUnreadable,
Detail: fmt.Sprintf("reading manifest: %v", err),
})
return result
}
var m manifestLike
if err := json.Unmarshal(data, &m); err != nil {
result.OK = false
result.Problems = append(result.Problems, BlobResult{
Digest: desc.Digest.String(),
Status: BlobUnreadable,
Detail: fmt.Sprintf("decoding manifest JSON: %v", err),
})
return result
}
var (
problemsMu sync.Mutex
g errgroup.Group
)
g.SetLimit(runtime.GOMAXPROCS(0))
addProblem := func(r BlobResult) {
problemsMu.Lock()
result.Problems = append(result.Problems, r)
problemsMu.Unlock()
}
if m.Config.Digest.Validate() == nil {
configDesc := m.Config
g.Go(func() error {
if r := c.checkMemo(configDesc); r.Status != BlobOK {
addProblem(r)
}
return nil
})
}
for _, l := range m.Layers {
if l.Digest.Validate() != nil {
continue
}
lyr := l
g.Go(func() error {
if r := c.checkMemo(lyr); r.Status != BlobOK {
addProblem(r)
}
return nil
})
}
// The scheduled functions above never return a non-nil error; g.Wait() is
// used purely to bound concurrency and wait for completion.
_ = g.Wait()
for _, child := range m.Manifests {
if child.Digest.Validate() != nil {
continue
}
childResult := c.Check(ctx, child)
if !childResult.OK {
result.Problems = append(result.Problems, childResult.Problems...)
}
}
if len(result.Problems) > 0 {
result.OK = false
}
return result
}