feat: add blob integrity check to info command (#699)

Signed-off-by: Adam Martin <adam.martin@ranchergovernment.com>
This commit is contained in:
Adam Martin
2026-08-05 10:13:12 -04:00
committed by GitHub
parent 7a84ef73d5
commit b178b3fa81
7 changed files with 2027 additions and 67 deletions
+12
View File
@@ -236,6 +236,18 @@ func addStoreInfo(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Comman
Short: "Print out information about the store",
Args: cobra.ExactArgs(0),
Aliases: []string{"i", "list", "ls"},
PreRunE: func(cmd *cobra.Command, args []string) error {
// suppress log output specifically for the --check + json combination so
// stdout stays pure, parseable JSON (--check emits progress/debug logs) --
// must be set before any log calls to keep stdout clean for piping, matching
// the `sync --dry-run` precedent. InfoCmd never logs anything on the success
// path when --check is off, so suppression is unnecessary (and would be a
// regression, silently swallowing e.g. ERR logs) for plain `-o json`.
if o.Check && o.OutputFormat == "json" {
log.FromContext(cmd.Context()).SetLevel("fatal")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
+441 -64
View File
@@ -14,6 +14,7 @@ import (
"hauler.dev/go/hauler/v2/internal/flags"
"hauler.dev/go/hauler/v2/pkg/consts"
"hauler.dev/go/hauler/v2/pkg/log"
"hauler.dev/go/hauler/v2/pkg/reference"
"hauler.dev/go/hauler/v2/pkg/store"
)
@@ -25,8 +26,24 @@ type infoOutput struct {
}
func InfoCmd(ctx context.Context, o *flags.InfoOpts, s *store.Layout) error {
var checker *store.Checker
if o.Check {
checker = s.NewChecker()
total := 0
_ = s.OCI.Walk(func(_ string, desc ocispec.Descriptor) error {
if _, ok := desc.Annotations[ocispec.AnnotationRefName]; ok {
total++
}
return nil
})
log.FromContext(ctx).Infof("checking integrity of %d artifacts... this reads every blob in the store", total)
}
var items []item
if err := s.Walk(func(ref string, desc ocispec.Descriptor) error {
var totalChecked, totalCorrupt int
if err := s.Walk(func(_ string, desc ocispec.Descriptor) error {
if _, ok := desc.Annotations[ocispec.AnnotationRefName]; !ok {
return nil
}
@@ -38,12 +55,35 @@ func InfoCmd(ctx context.Context, o *flags.InfoOpts, s *store.Layout) error {
// handle multi-arch images
if desc.MediaType == consts.OCIImageIndexSchema || desc.MediaType == consts.DockerManifestListSchema2 {
if o.Check && (o.TypeFilter == "all" || o.TypeFilter == "image") {
if res := checker.CheckBlob(desc); res.Status != store.BlobOK {
if addFallbackRow(&items, desc, "-", o, res) {
totalChecked++
totalCorrupt++
}
return nil
}
}
var idx ocispec.Index
if err := json.NewDecoder(rc).Decode(&idx); err != nil {
return err
if !o.Check {
return err
}
if addFallbackRow(&items, desc, "-", o, store.BlobResult{
Digest: desc.Digest.String(),
Status: store.BlobUnreadable,
Detail: fmt.Sprintf("image index JSON decode failed: %v", err),
}) {
totalChecked++
totalCorrupt++
}
return nil
}
for _, internalDesc := range idx.Manifests {
plat := fmt.Sprintf("%s/%s", internalDesc.Platform.OS, internalDesc.Platform.Architecture)
rc, err := s.Fetch(ctx, internalDesc)
if err != nil {
return err
@@ -52,19 +92,41 @@ func InfoCmd(ctx context.Context, o *flags.InfoOpts, s *store.Layout) error {
var internalManifest ocispec.Manifest
if err := json.NewDecoder(rc).Decode(&internalManifest); err != nil {
return err
if !o.Check {
return err
}
if addFallbackRow(&items, desc, plat, o, store.BlobResult{
Digest: internalDesc.Digest.String(),
Status: store.BlobUnreadable,
Detail: fmt.Sprintf("platform manifest JSON decode failed: %v", err),
}) {
totalChecked++
totalCorrupt++
}
continue
}
i := newItemWithDigest(
s,
internalDesc.Digest.String(),
desc,
internalManifest,
fmt.Sprintf("%s/%s", internalDesc.Platform.OS, internalDesc.Platform.Architecture),
o,
)
var emptyItem item
if i != emptyItem {
ctype := resolveCtype(desc, internalManifest.Config.MediaType)
if o.TypeFilter != "all" && ctype != o.TypeFilter {
continue
}
i := newItemWithDigest(s, internalDesc.Digest.String(), desc, internalManifest, plat, o)
if i.isEmpty() {
continue
}
if o.Check {
res := checker.Check(ctx, internalDesc)
totalChecked++
outcome := "ok"
if corrupt := attachProblems(&i, res); corrupt {
outcome = "corrupt"
totalCorrupt++
items = append(items, i)
}
log.FromContext(ctx).Debugf("checked %s (%s): %s", i.Reference, i.Platform, outcome)
} else {
items = append(items, i)
}
}
@@ -73,46 +135,122 @@ func InfoCmd(ctx context.Context, o *flags.InfoOpts, s *store.Layout) error {
} else if desc.MediaType == consts.DockerManifestSchema2 || desc.MediaType == consts.OCIManifestSchema1 {
var m ocispec.Manifest
if err := json.NewDecoder(rc).Decode(&m); err != nil {
return err
if !o.Check {
return err
}
if addFallbackRow(&items, desc, "-", o, store.BlobResult{
Digest: desc.Digest.String(),
Status: store.BlobUnreadable,
Detail: fmt.Sprintf("manifest JSON decode failed: %v", err),
}) {
totalChecked++
totalCorrupt++
}
return nil
}
rc, err := s.FetchManifest(ctx, m)
if err != nil {
return err
ctype := resolveCtype(desc, m.Config.MediaType)
if o.TypeFilter != "all" && ctype != o.TypeFilter {
return nil
}
defer rc.Close()
rc2, err := s.FetchManifest(ctx, m)
if err != nil {
if !o.Check {
return err
}
if addFallbackRow(&items, desc, "-", o, store.BlobResult{
Digest: m.Config.Digest.String(),
Status: store.BlobUnreadable,
Detail: fmt.Sprintf("fetching image config failed: %v", err),
}) {
totalChecked++
totalCorrupt++
}
return nil
}
defer rc2.Close()
// unmarshal the oci image content
var internalManifest ocispec.Image
if err := json.NewDecoder(rc).Decode(&internalManifest); err != nil {
return err
if err := json.NewDecoder(rc2).Decode(&internalManifest); err != nil {
if !o.Check {
return err
}
if addFallbackRow(&items, desc, "-", o, store.BlobResult{
Digest: m.Config.Digest.String(),
Status: store.BlobUnreadable,
Detail: fmt.Sprintf("image config JSON decode failed: %v", err),
}) {
totalChecked++
totalCorrupt++
}
return nil
}
plat := "-"
if internalManifest.Architecture != "" {
i := newItem(s, desc, m,
fmt.Sprintf("%s/%s", internalManifest.OS, internalManifest.Architecture), o)
var emptyItem item
if i != emptyItem {
plat = fmt.Sprintf("%s/%s", internalManifest.OS, internalManifest.Architecture)
}
i := newItem(s, desc, m, plat, o)
if i.isEmpty() {
return nil
}
if o.Check {
res := checker.Check(ctx, desc)
totalChecked++
outcome := "ok"
if corrupt := attachProblems(&i, res); corrupt {
outcome = "corrupt"
totalCorrupt++
items = append(items, i)
}
log.FromContext(ctx).Debugf("checked %s: %s", i.Reference, outcome)
} else {
i := newItem(s, desc, m, "-", o)
var emptyItem item
if i != emptyItem {
items = append(items, i)
}
items = append(items, i)
}
// handle everything else (charts, files, sigs, etc.)
} else {
var m ocispec.Manifest
if err := json.NewDecoder(rc).Decode(&m); err != nil {
return err
if !o.Check {
return err
}
if addFallbackRow(&items, desc, "-", o, store.BlobResult{
Digest: desc.Digest.String(),
Status: store.BlobUnreadable,
Detail: fmt.Sprintf("manifest JSON decode failed: %v", err),
}) {
totalChecked++
totalCorrupt++
}
return nil
}
ctype := resolveCtype(desc, m.Config.MediaType)
if o.TypeFilter != "all" && ctype != o.TypeFilter {
return nil
}
i := newItem(s, desc, m, "-", o)
var emptyItem item
if i != emptyItem {
if i.isEmpty() {
return nil
}
if o.Check {
res := checker.Check(ctx, desc)
totalChecked++
outcome := "ok"
if corrupt := attachProblems(&i, res); corrupt {
outcome = "corrupt"
totalCorrupt++
items = append(items, i)
}
log.FromContext(ctx).Debugf("checked %s: %s", i.Reference, outcome)
} else {
items = append(items, i)
}
}
@@ -130,6 +268,10 @@ func InfoCmd(ctx context.Context, o *flags.InfoOpts, s *store.Layout) error {
// sort items by ref and arch
sort.Sort(byReferenceAndArch(items))
if items == nil {
items = []item{}
}
switch o.OutputFormat {
case "json":
out := infoOutput{
@@ -143,10 +285,28 @@ func InfoCmd(ctx context.Context, o *flags.InfoOpts, s *store.Layout) error {
}
fmt.Println(string(data))
default:
if err := buildTable(s.Root, s.StoreID, o.ShowDigests, items...); err != nil {
return err
if o.Check {
if totalCorrupt > 0 {
if err := buildFailureTable(s.Root, s.StoreID, items...); err != nil {
return err
}
}
} else {
if err := buildTable(s.Root, s.StoreID, o.ShowDigests, items...); err != nil {
return err
}
}
}
if o.Check {
if totalCorrupt > 0 {
log.FromContext(ctx).Warnf("%d of %d artifacts failed the integrity check", totalCorrupt, totalChecked)
log.FromContext(ctx).Warnf("to remediate, remove and re-add the affected artifact(s)")
} else {
log.FromContext(ctx).Infof("all %d artifacts passed the integrity check", totalChecked)
}
}
return nil
}
@@ -174,6 +334,8 @@ func buildListRepos(items ...item) {
}
}
// buildTable renders the standard (non-check) inventory table: one row per item,
// with the shape unchanged from before the --check redesign.
func buildTable(storePath, storeID string, showDigests bool, items ...item) error {
table := tablewriter.NewTable(os.Stdout)
table.Configure(func(cfg *tablewriter.Config) {
@@ -238,6 +400,105 @@ func buildTable(storePath, storeID string, showDigests bool, items ...item) erro
return table.Render()
}
// issueColumnMaxWidth bounds the failure table's Issue column so a long,
// unbounded issueText value (e.g. a filesystem path embedded in an
// "unreadable: ..." error) can't blow out the whole table's layout in a
// terminal. Chosen to keep the overall table within a normal terminal width
// alongside the Reference/Type/Platform/Digest columns.
const issueColumnMaxWidth = 50
// buildFailureTable renders the --check failure report: a fixed-column table
// (Reference | Type | Platform | Digest | Issue) containing only the artifacts
// that failed their integrity check, one row per problem so an artifact with
// multiple bad blobs gets multiple rows. This shape does not vary with
// showDigests/o.ShowDigests -- that flag only affects the non-check inventory
// table produced by buildTable. The Issue column is word-wrapped (with
// mid-token breaks, see the AutoWrap comment below) to a fixed width so an
// unbounded issueText value can't blow out the table's layout.
func buildFailureTable(storePath, storeID string, items ...item) error {
table := tablewriter.NewTable(os.Stdout)
table.Configure(func(cfg *tablewriter.Config) {
cfg.Header.Alignment.Global = tw.AlignLeft
cfg.Footer.Alignment.PerColumn = []tw.Align{tw.AlignLeft}
cfg.Row.Merging.Mode = tw.MergeVertical
cfg.Row.Merging.ByColumnIndex = tw.NewBoolMapper(0)
// The Issue column (index 4) can contain an arbitrary filesystem path or
// error string with no spaces (e.g. "unreadable: /a/very/long/path...").
// tw.WrapNormal only breaks on word boundaries, so a long unbroken token
// would sail straight through it and blow out the table layout -- verified
// empirically. tw.WrapBreak forces a mid-token break once the column
// reaches its max width, so use that instead.
cfg.Row.Formatting.AutoWrap = tw.WrapBreak
cfg.Row.ColMaxWidths.PerColumn = tw.NewMapper[int, int]().Set(4, issueColumnMaxWidth)
})
table.Header("Reference", "Type", "Platform", "Digest", "Issue")
for _, i := range items {
if i.Type == "" {
continue
}
ref := truncateReference(i.Reference)
if len(i.blobProblems) == 0 {
if err := table.Append([]string{ref, i.Type, i.Platform, "-", "unknown failure"}); err != nil {
return err
}
continue
}
for _, p := range i.blobProblems {
row := []string{ref, i.Type, i.Platform, truncateDigest(p.Digest), issueText(p)}
if err := table.Append(row); err != nil {
return err
}
}
}
table.Footer("store-path: "+storePath+"\nstore-id: "+storeID, "", "", "", "")
return table.Render()
}
// truncateDigest shortens a "sha256:<hex>" digest string to its first ~12 hex
// characters followed by an ellipsis, for compact display in the failure table's
// Digest column.
func truncateDigest(d string) string {
const prefix = "sha256:"
if !strings.HasPrefix(d, prefix) {
return d
}
hex := strings.TrimPrefix(d, prefix)
if len(hex) > 12 {
return prefix + hex[:12] + "…"
}
return d
}
// issueText renders a store.BlobResult's status/detail as a short human-readable
// reason for the failure table's Issue column.
func issueText(r store.BlobResult) string {
switch r.Status {
case store.BlobMissing:
return "missing"
case store.BlobSizeMismatch:
if r.Detail != "" {
return "size mismatch: " + r.Detail
}
return "size mismatch"
case store.BlobDigestMismatch:
return "digest mismatch"
case store.BlobUnreadable:
if r.Detail != "" {
return "unreadable: " + r.Detail
}
return "unreadable"
default:
return string(r.Status)
}
}
// truncateReference shortens the digest of a reference
func truncateReference(ref string) string {
const prefix = "@sha256:"
@@ -252,12 +513,26 @@ func truncateReference(ref string) string {
}
type item struct {
Reference string `json:"reference"`
Type string `json:"type"`
Platform string `json:"platform"`
Digest string `json:"digest,omitempty"`
Layers int `json:"layers"`
Size int64 `json:"size"`
Reference string `json:"reference"`
Type string `json:"type"`
Platform string `json:"platform"`
Digest string `json:"digest,omitempty"`
Layers int `json:"layers"`
Size int64 `json:"size"`
Problems []string `json:"problems,omitempty"` // populated only for corrupt items
// blobProblems holds the same information as Problems but as structured
// store.BlobResult values, used by buildFailureTable to render one row per
// problem (Digest/Issue columns). Not exported to JSON.
blobProblems []store.BlobResult
}
// isEmpty reports whether i is the zero-value item returned by newItem/newItem*
// helpers to signal "filtered out or unparseable ref". item cannot use == because
// Problems is a slice, so callers must use this instead of comparing against a
// zero-value item literal.
func (i item) isEmpty() bool {
return i.Type == ""
}
type byReferenceAndArch []item
@@ -293,31 +568,9 @@ func newItem(s *store.Layout, desc ocispec.Descriptor, m ocispec.Manifest, plat
size += l.Size
}
// Generate a human-readable content type
var ctype string
switch m.Config.MediaType {
case consts.DockerConfigJSON:
ctype = "image"
case consts.ChartConfigMediaType:
ctype = "chart"
case consts.FileLocalConfigMediaType, consts.FileHttpConfigMediaType:
ctype = "file"
default:
ctype = "image"
}
ctype := resolveCtype(desc, m.Config.MediaType)
switch {
case desc.Annotations[consts.KindAnnotationName] == consts.KindAnnotationSigs:
ctype = "sigs"
case desc.Annotations[consts.KindAnnotationName] == consts.KindAnnotationAtts:
ctype = "atts"
case desc.Annotations[consts.KindAnnotationName] == consts.KindAnnotationSboms:
ctype = "sbom"
case strings.HasPrefix(desc.Annotations[consts.KindAnnotationName], consts.KindAnnotationReferrers):
ctype = "referrer"
}
refName := desc.Annotations["io.containerd.image.name"]
refName := desc.Annotations[consts.ContainerdImageNameKey]
if refName == "" {
refName = desc.Annotations[ocispec.AnnotationRefName]
}
@@ -340,6 +593,130 @@ func newItem(s *store.Layout, desc ocispec.Descriptor, m ocispec.Manifest, plat
}
}
// resolveCtype computes the human-readable content type ("image", "chart", "file",
// "sigs", "atts", "sbom", "referrer") for a descriptor. configMediaType is the
// manifest's config media type and is used to distinguish image/chart/file when the
// kind annotation doesn't already identify a more specific type; it may be empty
// when the manifest could not be decoded (the --check fallback-row path), in which
// case ctype defaults to "image" unless the kind annotation says otherwise.
func resolveCtype(desc ocispec.Descriptor, configMediaType string) string {
var ctype string
switch configMediaType {
case consts.ChartConfigMediaType:
ctype = "chart"
case consts.FileLocalConfigMediaType, consts.FileHttpConfigMediaType:
ctype = "file"
default:
ctype = "image"
}
switch {
case desc.Annotations[consts.KindAnnotationName] == consts.KindAnnotationSigs:
ctype = "sigs"
case desc.Annotations[consts.KindAnnotationName] == consts.KindAnnotationAtts:
ctype = "atts"
case desc.Annotations[consts.KindAnnotationName] == consts.KindAnnotationSboms:
ctype = "sbom"
case strings.HasPrefix(desc.Annotations[consts.KindAnnotationName], consts.KindAnnotationReferrers):
ctype = "referrer"
}
return ctype
}
// fallbackItem builds a synthetic failure row directly from desc's index
// annotations -- no blob read beyond what's already been done is required. It is
// used under --check when a manifest can't be trusted: its own digest check
// failed, or its bytes decoded but the JSON was malformed. plat defaults to "-".
//
// Type is resolved with an empty configMediaType, since charts/files carry the same
// KindAnnotationImage as regular images in the store index and so cannot be told
// apart from an image once the manifest itself can't be decoded; this is an accepted
// limitation, not a bug.
func fallbackItem(desc ocispec.Descriptor, plat string, problem store.BlobResult) item {
if plat == "" {
plat = "-"
}
refName := desc.Annotations[consts.ContainerdImageNameKey]
if refName == "" {
refName = desc.Annotations[ocispec.AnnotationRefName]
}
ref, err := reference.Parse(refName)
if err != nil {
return item{}
}
return item{
Reference: ref.Name(),
Type: resolveCtype(desc, ""),
Platform: plat,
Layers: 0,
Size: 0,
Problems: problemStrings([]store.BlobResult{problem}),
blobProblems: []store.BlobResult{problem},
}
}
// addFallbackRow appends a fallbackItem for desc unless its best-guess type doesn't
// match an active --type filter, in which case it is silently dropped -- exactly
// like any other filtered-out artifact -- so a filtered corrupt row costs nothing.
// It returns whether the row was actually appended, so callers can keep their
// totalChecked/totalCorrupt counters in sync with what's shown: incrementing
// unconditionally would overcount relative to a row silently dropped by a --type
// filter.
func addFallbackRow(items *[]item, desc ocispec.Descriptor, plat string, o *flags.InfoOpts, problem store.BlobResult) bool {
row := fallbackItem(desc, plat, problem)
if row.isEmpty() {
return false
}
if o.TypeFilter != "all" && row.Type != o.TypeFilter {
return false
}
*items = append(*items, row)
return true
}
// attachProblems populates i.Problems/i.blobProblems from a store.CheckResult and
// reports whether the artifact is corrupt (res.OK == false).
func attachProblems(i *item, res store.CheckResult) bool {
if res.OK {
return false
}
i.Problems = problemStrings(res.Problems)
i.blobProblems = res.Problems
return true
}
// problemStrings converts a slice of store.BlobResult problems into human-readable
// strings suitable for JSON output (words, never glyphs).
func problemStrings(problems []store.BlobResult) []string {
out := make([]string, 0, len(problems))
for _, p := range problems {
out = append(out, problemMessage(p))
}
return out
}
// problemMessage renders a single store.BlobResult as a human-readable string, e.g.
// "sha256:abc...: digest mismatch (content does not match its digest)".
func problemMessage(r store.BlobResult) string {
word := string(r.Status)
switch r.Status {
case store.BlobMissing:
word = "blob missing"
case store.BlobSizeMismatch:
word = "size mismatch"
case store.BlobDigestMismatch:
word = "digest mismatch"
case store.BlobUnreadable:
word = "unreadable"
}
if r.Detail != "" {
return fmt.Sprintf("%s: %s (%s)", r.Digest, word, r.Detail)
}
return fmt.Sprintf("%s: %s", r.Digest, word)
}
func byteCountSI(b int64) string {
const unit = 1000
if b < unit {
+649 -3
View File
@@ -1,17 +1,36 @@
package store
import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
digest "github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/rs/zerolog"
"hauler.dev/go/hauler/v2/internal/flags"
v1 "hauler.dev/go/hauler/v2/pkg/apis/hauler.cattle.io/v1"
"hauler.dev/go/hauler/v2/pkg/consts"
"hauler.dev/go/hauler/v2/pkg/store"
)
// newCapturingContext returns a context carrying a zerolog logger that writes
// directly into buf, so log.FromContext output can be asserted on without the
// os.Stdout redirection tricks that captureStdout uses -- pkg/log.NewLogger
// hardcodes its writer to os.Stdout regardless of the io.Writer passed to it, so
// it cannot be used to capture log output in tests.
func newCapturingContext(buf *bytes.Buffer) context.Context {
zerolog.SetGlobalLevel(zerolog.InfoLevel) // defensive
zl := zerolog.New(buf).Level(zerolog.InfoLevel).With().Timestamp().Logger()
return zl.WithContext(context.Background())
}
func TestByteCountSI(t *testing.T) {
tests := []struct {
input int64
@@ -180,14 +199,13 @@ func TestNewItem(t *testing.T) {
o := &flags.InfoOpts{TypeFilter: tc.typeFilter}
got := newItem(nil, desc, m, "linux/amd64", o)
var empty item
if tc.wantEmpty {
if got != empty {
if !got.isEmpty() {
t.Errorf("expected empty item, got %+v", got)
}
return
}
if got == empty {
if got.isEmpty() {
t.Fatalf("got empty item, want type %q", tc.wantType)
}
if got.Type != tc.wantType {
@@ -244,3 +262,631 @@ func TestInfoCmd(t *testing.T) {
}
})
}
// captureStdout redirects os.Stdout for the duration of fn and returns everything
// written to it, along with fn's return value.
func captureStdout(t *testing.T, fn func() error) (string, error) {
t.Helper()
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
os.Stdout = w
fnErr := fn()
w.Close()
os.Stdout = oldStdout
var buf strings.Builder
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("read captured stdout: %v", err)
}
r.Close()
return buf.String(), fnErr
}
// findManifestDescriptor walks s and returns the descriptor for the first entry
// whose AnnotationRefName contains refSubstring. Fails the test if none is found.
func findManifestDescriptor(t *testing.T, s *store.Layout, refSubstring string) ocispec.Descriptor {
t.Helper()
var found ocispec.Descriptor
if err := s.OCI.Walk(func(_ string, desc ocispec.Descriptor) error {
if strings.Contains(desc.Annotations[ocispec.AnnotationRefName], refSubstring) {
found = desc
}
return nil
}); err != nil {
t.Fatalf("walk: %v", err)
}
if found.Digest == "" {
t.Fatalf("no artifact found for ref containing %q", refSubstring)
}
return found
}
// corruptBlobFile flips the first byte of the blob identified by d, preserving its
// exact length -- a same-length corruption that only a real digest check can catch.
func corruptBlobFile(t *testing.T, root string, d digest.Digest) {
t.Helper()
path := filepath.Join(root, ocispec.ImageBlobsDir, d.Algorithm().String(), d.Hex())
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read blob %s: %v", d, err)
}
corrupted := append([]byte(nil), data...)
corrupted[0] ^= 0xFF
if err := os.WriteFile(path, corrupted, 0o644); err != nil {
t.Fatalf("corrupt blob %s: %v", d, err)
}
}
// firstLayerDigest reads and parses the manifest blob for desc and returns the
// digest of its first layer. Fails the test if the manifest has no layers.
func firstLayerDigest(t *testing.T, root string, desc ocispec.Descriptor) digest.Digest {
t.Helper()
path := filepath.Join(root, ocispec.ImageBlobsDir, desc.Digest.Algorithm().String(), desc.Digest.Hex())
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read manifest blob %s: %v", desc.Digest, err)
}
var m ocispec.Manifest
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal manifest %s: %v", desc.Digest, err)
}
if len(m.Layers) == 0 {
t.Fatalf("manifest %s has no layers", desc.Digest)
}
return m.Layers[0].Digest
}
// TestInfoCmd_CheckHealthyStore checks that --check on a store with no corruption
// reports an empty artifacts array in JSON output (a failure report shows only
// corrupt artifacts, and there are none here) -- never null, so downstream jq
// pipelines can rely on the array always being present.
func TestInfoCmd_CheckHealthyStore(t *testing.T) {
ctx := newTestContext(t)
s := newTestStore(t)
host, opts := newTestRegistry(t)
seedImage(t, host, "test/healthy", "v1", opts...)
if _, err := s.AddImage(ctx, host+"/test/healthy:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage: %v", err)
}
tmpFile := t.TempDir() + "/hello.txt"
if err := os.WriteFile(tmpFile, []byte("hello hauler"), 0o644); err != nil {
t.Fatalf("write tmpFile: %v", err)
}
if err := storeFile(ctx, s, v1.File{Path: tmpFile}, defaultCliOpts(), defaultRootOpts(s.Root)); err != nil {
t.Fatalf("storeFile: %v", err)
}
o := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "json",
TypeFilter: "all",
Check: true,
}
out, err := captureStdout(t, func() error { return InfoCmd(ctx, o, s) })
if err != nil {
t.Fatalf("InfoCmd: %v\noutput: %s", err, out)
}
var got infoOutput
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("unmarshal output: %v\noutput: %s", err, out)
}
if len(got.Artifacts) != 0 {
t.Errorf("expected zero artifacts in a healthy-store failure report, got %d: %+v", len(got.Artifacts), got.Artifacts)
}
// The raw JSON must render the artifacts array as "[]", never "null" --
// jq pipelines depend on this.
if !strings.Contains(out, `"artifacts": []`) {
t.Errorf("expected raw JSON to contain an empty artifacts array (\"artifacts\": []), got: %s", out)
}
}
// TestInfoCmd_CheckCorruptBlob_HealthySiblingOmitted checks that --check reports
// only the artifact whose blob was corrupted (with non-empty problems), while a
// healthy sibling artifact is omitted entirely from the failure report -- not
// present-with-no-problems, actually absent. InfoCmd returns nil even though
// corruption is found; reporting happens via the JSON payload / failure table and
// a WARN log line, never via a non-zero exit.
func TestInfoCmd_CheckCorruptBlob_HealthySiblingOmitted(t *testing.T) {
ctx := newTestContext(t)
s := newTestStore(t)
host, opts := newTestRegistry(t)
seedImage(t, host, "test/corrupt-a", "v1", opts...)
if _, err := s.AddImage(ctx, host+"/test/corrupt-a:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage corrupt-a: %v", err)
}
seedImage(t, host, "test/healthy-b", "v1", opts...)
if _, err := s.AddImage(ctx, host+"/test/healthy-b:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage healthy-b: %v", err)
}
desc := findManifestDescriptor(t, s, "test/corrupt-a")
victim := firstLayerDigest(t, s.Root, desc)
corruptBlobFile(t, s.Root, victim)
o := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "json",
TypeFilter: "all",
Check: true,
}
out, err := captureStdout(t, func() error { return InfoCmd(ctx, o, s) })
if err != nil {
t.Fatalf("InfoCmd: %v (corrupt artifacts must be reported via WARN log + JSON payload, not a returned error)\noutput: %s", err, out)
}
if out == "" {
t.Fatal("expected InfoCmd to print output")
}
var got infoOutput
if jerr := json.Unmarshal([]byte(out), &got); jerr != nil {
t.Fatalf("unmarshal output: %v\noutput: %s", jerr, out)
}
if len(got.Artifacts) != 1 {
t.Fatalf("expected exactly one artifact in the failure report, got %d: %+v", len(got.Artifacts), got.Artifacts)
}
var sawCorrupt bool
for _, a := range got.Artifacts {
if strings.Contains(a.Reference, "healthy-b") {
t.Errorf("healthy-b must be omitted entirely from the failure report, got: %+v", a)
}
if strings.Contains(a.Reference, "corrupt-a") {
sawCorrupt = true
if len(a.Problems) == 0 {
t.Errorf("corrupt-a: expected non-empty problems")
}
}
}
if !sawCorrupt {
t.Error("expected a row for test/corrupt-a")
}
}
// TestInfoCmd_CheckCorruptManifest_RegressionGuard is the regression guard for the
// bug this feature must not perpetuate: today, a JSON decode error thrown while
// walking the store kills the entire `hauler store info` command. Under --check, a
// corrupted manifest blob must instead degrade to a single "corrupt" row, and
// InfoCmd must still render output and return nil despite the corruption, instead
// of the old hard-failure behavior.
func TestInfoCmd_CheckCorruptManifest_RegressionGuard(t *testing.T) {
ctx := newTestContext(t)
s := newTestStore(t)
host, opts := newTestRegistry(t)
seedImage(t, host, "test/badmanifest", "v1", opts...)
if _, err := s.AddImage(ctx, host+"/test/badmanifest:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage: %v", err)
}
desc := findManifestDescriptor(t, s, "test/badmanifest")
corruptBlobFile(t, s.Root, desc.Digest)
o := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "json",
TypeFilter: "all",
Check: true,
}
out, err := captureStdout(t, func() error { return InfoCmd(ctx, o, s) })
if err != nil {
t.Fatalf("InfoCmd: %v (must return nil even when a corrupted manifest is found; reporting is via WARN log + status row)\noutput: %s", err, out)
}
if out == "" {
t.Fatal("expected InfoCmd to still render output despite the corrupted manifest, not abort silently")
}
var got infoOutput
if jerr := json.Unmarshal([]byte(out), &got); jerr != nil {
t.Fatalf("unmarshal output: %v\noutput: %s", jerr, out)
}
if len(got.Artifacts) != 1 {
t.Fatalf("expected exactly one row, got %d: %+v", len(got.Artifacts), got.Artifacts)
}
if len(got.Artifacts[0].Problems) == 0 {
t.Error("expected non-empty problems for the corrupted manifest row")
}
}
// TestInfoCmd_NoCheck_OutputUnchanged checks that omitting --check produces
// byte-for-byte the same JSON shape as before this feature existed: no "status" or
// "problems" key appears anywhere in the output (both are omitempty).
func TestInfoCmd_NoCheck_OutputUnchanged(t *testing.T) {
ctx := newTestContext(t)
s := newTestStore(t)
host, opts := newTestRegistry(t)
seedImage(t, host, "test/plain", "v1", opts...)
if _, err := s.AddImage(ctx, host+"/test/plain:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage: %v", err)
}
o := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "json",
TypeFilter: "all",
// Check intentionally left false (zero value).
}
out, err := captureStdout(t, func() error { return InfoCmd(ctx, o, s) })
if err != nil {
t.Fatalf("InfoCmd: %v\noutput: %s", err, out)
}
if strings.Contains(out, `"status"`) {
t.Errorf("expected no \"status\" key in output when --check is off, got: %s", out)
}
if strings.Contains(out, `"problems"`) {
t.Errorf("expected no \"problems\" key in output when --check is off, got: %s", out)
}
var raw map[string]interface{}
if jerr := json.Unmarshal([]byte(out), &raw); jerr != nil {
t.Fatalf("unmarshal output: %v\noutput: %s", jerr, out)
}
artifacts, ok := raw["artifacts"].([]interface{})
if !ok || len(artifacts) == 0 {
t.Fatalf("expected at least one artifact, got: %+v", raw)
}
for _, a := range artifacts {
am, ok := a.(map[string]interface{})
if !ok {
t.Fatalf("artifact entry is not an object: %+v", a)
}
if _, present := am["status"]; present {
t.Errorf("artifact has a \"status\" key when --check is off: %+v", am)
}
if _, present := am["problems"]; present {
t.Errorf("artifact has a \"problems\" key when --check is off: %+v", am)
}
}
}
// TestInfoCmd_CheckWithTypeFilter_SkipsFilteredCorruption checks that combining
// --check with --type filters out non-matching artifacts before the integrity check,
// so a corrupt artifact of a filtered-out type produces neither a row nor an error --
// and since the one remaining (image) artifact is healthy, the failure report ends up
// empty entirely.
func TestInfoCmd_CheckWithTypeFilter_SkipsFilteredCorruption(t *testing.T) {
ctx := newTestContext(t)
s := newTestStore(t)
host, opts := newTestRegistry(t)
seedImage(t, host, "test/filterhealthy", "v1", opts...)
if _, err := s.AddImage(ctx, host+"/test/filterhealthy:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage: %v", err)
}
// Add a real chart from testdata (per repo convention) and corrupt one of its
// layer blobs -- a non-image artifact that --type image should filter out
// before ever attempting the integrity check.
rso := defaultRootOpts(s.Root)
ro := defaultCliOpts()
chartOpts := newAddChartOpts(chartTestdataDir, "")
if err := AddChartCmd(ctx, chartOpts, s, "rancher-cluster-templates-0.5.2.tgz", rso, ro); err != nil {
t.Fatalf("AddChartCmd: %v", err)
}
chartDesc := findManifestDescriptor(t, s, "rancher-cluster-templates")
chartVictim := firstLayerDigest(t, s.Root, chartDesc)
corruptBlobFile(t, s.Root, chartVictim)
o := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "json",
TypeFilter: "image",
Check: true,
}
out, err := captureStdout(t, func() error { return InfoCmd(ctx, o, s) })
if err != nil {
t.Fatalf("InfoCmd: %v (the filtered-out corrupt chart must not surface an error)\noutput: %s", err, out)
}
var got infoOutput
if jerr := json.Unmarshal([]byte(out), &got); jerr != nil {
t.Fatalf("unmarshal output: %v\noutput: %s", jerr, out)
}
if len(got.Artifacts) != 0 {
t.Errorf("expected zero artifacts (the corrupt chart is filtered out, and the remaining image is healthy), got %d: %+v", len(got.Artifacts), got.Artifacts)
}
}
// TestInfoCmd_CheckHealthyStore_TableFormat_NoTableRendered checks that --check on
// a healthy store, with table output requested, renders no table at all -- only an
// INFO log line reporting that every artifact passed.
func TestInfoCmd_CheckHealthyStore_TableFormat_NoTableRendered(t *testing.T) {
origLevel := zerolog.GlobalLevel()
t.Cleanup(func() { zerolog.SetGlobalLevel(origLevel) })
s := newTestStore(t)
host, opts := newTestRegistry(t)
seedImage(t, host, "test/healthytable", "v1", opts...)
var buf bytes.Buffer
ctx := newCapturingContext(&buf)
if _, err := s.AddImage(ctx, host+"/test/healthytable:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage: %v", err)
}
o := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "table",
TypeFilter: "all",
Check: true,
}
stdout, err := captureStdout(t, func() error { return InfoCmd(ctx, o, s) })
if err != nil {
t.Fatalf("InfoCmd: %v\nstdout: %s", err, stdout)
}
if strings.Contains(stdout, "Reference") {
t.Errorf("expected no table header on stdout for a healthy store, got: %q", stdout)
}
for _, borderChar := range []string{"┌", "├", "└", "│"} {
if strings.Contains(stdout, borderChar) {
t.Errorf("expected no table border characters on stdout for a healthy store, got: %q", stdout)
}
}
if !strings.Contains(buf.String(), "passed the integrity check") {
t.Errorf("expected log output to contain the pass summary, got: %q", buf.String())
}
}
// TestInfoCmd_CheckCorruptBlob_RemediationHintLogged checks that when --check finds
// corruption, InfoCmd logs a single generic remediation statement telling the user
// they can fix it by removing and re-adding the affected artifact(s) -- not a
// per-artifact list of `hauler store remove <ref>` commands. A generic statement
// avoids walking users into ref-matching edge cases (see e.g. the nameMap-key vs.
// fully-qualified-ref mismatch that made copy-pasted per-ref hints unreliable).
func TestInfoCmd_CheckCorruptBlob_RemediationHintLogged(t *testing.T) {
origLevel := zerolog.GlobalLevel()
t.Cleanup(func() { zerolog.SetGlobalLevel(origLevel) })
s := newTestStore(t)
host, opts := newTestRegistry(t)
var buf bytes.Buffer
ctx := newCapturingContext(&buf)
seedImage(t, host, "test/remediation", "v1", opts...)
if _, err := s.AddImage(ctx, host+"/test/remediation:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage: %v", err)
}
desc := findManifestDescriptor(t, s, "test/remediation")
victim := firstLayerDigest(t, s.Root, desc)
corruptBlobFile(t, s.Root, victim)
o := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "table",
TypeFilter: "all",
Check: true,
}
if _, err := captureStdout(t, func() error { return InfoCmd(ctx, o, s) }); err != nil {
t.Fatalf("InfoCmd: %v", err)
}
logOutput := buf.String()
if strings.Contains(logOutput, "hauler store remove") {
t.Errorf("expected no per-artifact `hauler store remove` command, got: %q", logOutput)
}
if !strings.Contains(logOutput, "remove and re-add") {
t.Errorf("expected a generic remediation statement mentioning removing and re-adding the artifact, got: %q", logOutput)
}
if !strings.Contains(logOutput, "failed the integrity check") {
t.Errorf("expected a WARN summary with the failure count, got: %q", logOutput)
}
}
// TestInfoCmd_RemediationHint_DedupedForMultiPlatformImage checks that a
// multi-platform image failing its integrity check on more than one platform still
// produces exactly ONE generic remediation statement, not one per failing platform.
func TestInfoCmd_RemediationHint_DedupedForMultiPlatformImage(t *testing.T) {
ctx := newTestContext(t)
s := newTestStore(t)
host, opts := newTestRegistry(t)
var buf bytes.Buffer
logCtx := newCapturingContext(&buf)
idx := seedIndex(t, host, "test/remediation-multiarch", "v1", opts...)
if _, err := s.AddImage(ctx, host+"/test/remediation-multiarch:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage: %v", err)
}
manifests, err := idx.IndexManifest()
if err != nil {
t.Fatalf("idx.IndexManifest: %v", err)
}
if len(manifests.Manifests) < 2 {
t.Fatalf("expected at least 2 platform manifests, got %d", len(manifests.Manifests))
}
for _, pm := range manifests.Manifests {
platImg, err := idx.Image(pm.Digest)
if err != nil {
t.Fatalf("idx.Image(%s): %v", pm.Digest, err)
}
layers, err := platImg.Layers()
if err != nil {
t.Fatalf("platImg.Layers: %v", err)
}
if len(layers) == 0 {
t.Fatalf("platform manifest %s has no layers", pm.Digest)
}
ld, err := layers[0].Digest()
if err != nil {
t.Fatalf("layer.Digest: %v", err)
}
corruptBlobFile(t, s.Root, digest.NewDigestFromHex(ld.Algorithm, ld.Hex))
}
o := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "table",
TypeFilter: "all",
Check: true,
}
if _, err := captureStdout(t, func() error { return InfoCmd(logCtx, o, s) }); err != nil {
t.Fatalf("InfoCmd: %v", err)
}
logOutput := buf.String()
if strings.Contains(logOutput, "hauler store remove") {
t.Errorf("expected no per-artifact `hauler store remove` command, got: %q", logOutput)
}
if n := strings.Count(logOutput, "remove and re-add"); n != 1 {
t.Errorf("expected exactly 1 generic remediation statement for a multi-platform failure, got %d\nlog output: %s", n, logOutput)
}
}
// TestInfoCmd_CheckMultipleBadBlobs_OneRowPerProblem checks that an artifact with
// multiple corrupted blobs produces one Problems entry per bad blob in JSON output,
// and one table row per problem (only column 0, Reference, is vertically merged).
func TestInfoCmd_CheckMultipleBadBlobs_OneRowPerProblem(t *testing.T) {
ctx := newTestContext(t)
s := newTestStore(t)
host, opts := newTestRegistry(t)
img := seedImage(t, host, "test/doublebad", "v1", opts...)
if _, err := s.AddImage(ctx, host+"/test/doublebad:v1", "", true, opts...); err != nil {
t.Fatalf("AddImage: %v", err)
}
layers, err := img.Layers()
if err != nil {
t.Fatalf("img.Layers: %v", err)
}
if len(layers) < 2 {
t.Fatalf("expected at least 2 layers, got %d", len(layers))
}
for _, l := range layers {
d, err := l.Digest()
if err != nil {
t.Fatalf("layer.Digest: %v", err)
}
corruptBlobFile(t, s.Root, digest.NewDigestFromHex(d.Algorithm, d.Hex))
}
jsonOpts := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "json",
TypeFilter: "all",
Check: true,
}
out, err := captureStdout(t, func() error { return InfoCmd(ctx, jsonOpts, s) })
if err != nil {
t.Fatalf("InfoCmd (json): %v\noutput: %s", err, out)
}
var got infoOutput
if jerr := json.Unmarshal([]byte(out), &got); jerr != nil {
t.Fatalf("unmarshal output: %v\noutput: %s", jerr, out)
}
if len(got.Artifacts) != 1 {
t.Fatalf("expected exactly one artifact, got %d: %+v", len(got.Artifacts), got.Artifacts)
}
if len(got.Artifacts[0].Problems) != 2 {
t.Errorf("expected 2 problems (one per corrupted layer), got %d: %+v", len(got.Artifacts[0].Problems), got.Artifacts[0].Problems)
}
tableOpts := &flags.InfoOpts{
StoreRootOpts: defaultRootOpts(s.Root),
OutputFormat: "table",
TypeFilter: "all",
Check: true,
}
stdout, err := captureStdout(t, func() error { return InfoCmd(ctx, tableOpts, s) })
if err != nil {
t.Fatalf("InfoCmd (table): %v\nstdout: %s", err, stdout)
}
if n := strings.Count(stdout, "digest mismatch"); n != 2 {
t.Errorf("expected \"digest mismatch\" to appear twice (one row per problem), got %d occurrences in: %s", n, stdout)
}
}
// maxLineWidth is a regression trip-wire for any single rendered line of the
// failure table. It is not a strict terminal-width budget: the full row also
// carries Reference/Type/Platform/Digest columns, table border characters, and
// the footer label, all of which vary independently of the Issue column. It is
// set comfortably above the width produced by a correctly-bounded Issue column
// (observed ~125 chars for this test's fixture data) and comfortably below the
// width produced by an unbounded Issue column (observed 471-585 chars before
// this fix), so it fails only when the Issue column regresses to unbounded.
const maxLineWidth = 200
// TestBuildFailureTable_LongIssueIsWrapped guards against the Issue column (built
// from issueText, which can embed an arbitrary filesystem or JSON-decode error)
// blowing out the whole table's layout. It covers both a normal-length issue and
// the realistic worst case: a single unbroken ~180-character token with no spaces
// (e.g. a long filesystem path embedded in an "unreadable: ..." error), which a
// naive *word*-wrapping configuration would fail to wrap at all.
func TestBuildFailureTable_LongIssueIsWrapped(t *testing.T) {
longPath := "/some/very/long/path/with/no/spaces/that/keeps/going/and/going/and/going/and/going/and/going/and/going/and/going/blob.json"
if len(longPath) < 100 {
t.Fatalf("test setup: longPath too short for a meaningful regression check: %d chars", len(longPath))
}
items := []item{
{
Reference: "repo/normal:v1",
Type: "image",
Platform: "linux/amd64",
blobProblems: []store.BlobResult{
{Digest: "sha256:" + strings.Repeat("a", 64), Status: store.BlobDigestMismatch},
},
},
{
Reference: "repo/unreadable:v1",
Type: "image",
Platform: "linux/amd64",
blobProblems: []store.BlobResult{
{
Digest: "sha256:" + strings.Repeat("b", 64),
Status: store.BlobUnreadable,
Detail: longPath + ": no such file or directory",
},
},
},
}
out, err := captureStdout(t, func() error {
return buildFailureTable("/store/root", "store-id-123", items...)
})
if err != nil {
t.Fatalf("buildFailureTable: %v", err)
}
if !strings.Contains(out, "digest mismatch") {
t.Errorf("expected normal-length issue %q to appear in output:\n%s", "digest mismatch", out)
}
for i, line := range strings.Split(out, "\n") {
if n := len([]rune(line)); n > maxLineWidth {
t.Errorf("line %d exceeds max width %d (got %d): %q\nfull output:\n%s", i, maxLineWidth, n, line, out)
}
}
}
+244
View File
@@ -0,0 +1,244 @@
package cli
// store_info_check_test.go is a regression guard for `hauler store info --check`
// exercised through the real cobra command wiring (addStoreInfo's PreRunE + RunE),
// not by calling store.InfoCmd directly. The existing coverage in
// cmd/hauler/cli/store/info_test.go calls InfoCmd directly and so never exercises
// addStoreInfo's PreRunE, which is exactly why the Fix 1/2 bugs (corruption summary
// silently swallowed by the json-output log suppression, and that suppression firing
// even when --check was never requested) went undetected until manual e2e testing.
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
"github.com/google/go-containerregistry/pkg/v1/random"
"github.com/google/go-containerregistry/pkg/v1/remote"
digest "github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/rs/zerolog"
"hauler.dev/go/hauler/v2/internal/flags"
"hauler.dev/go/hauler/v2/pkg/log"
"hauler.dev/go/hauler/v2/pkg/store"
)
// newInfoCheckTestStore creates a fresh store in a temp directory along with an
// in-memory OCI registry (backed by httptest) that the store can pull from.
func newInfoCheckTestStore(t *testing.T) (s *store.Layout, host string) {
t.Helper()
s, err := store.NewLayout(t.TempDir())
if err != nil {
t.Fatalf("store.NewLayout: %v", err)
}
srv := httptest.NewServer(registry.New())
t.Cleanup(srv.Close)
host = strings.TrimPrefix(srv.URL, "http://")
return s, host
}
// seedInfoCheckImage pushes a random single-platform image to host/repo:tag.
func seedInfoCheckImage(t *testing.T, host, repo, tag string) {
t.Helper()
img, err := random.Image(512, 2)
if err != nil {
t.Fatalf("random.Image: %v", err)
}
ref, err := name.NewTag(host+"/"+repo+":"+tag, name.Insecure)
if err != nil {
t.Fatalf("name.NewTag: %v", err)
}
if err := remote.Write(ref, img); err != nil {
t.Fatalf("remote.Write: %v", err)
}
}
// findInfoCheckManifestDescriptor walks s and returns the descriptor for the first
// entry whose AnnotationRefName contains refSubstring. Fails the test if none found.
func findInfoCheckManifestDescriptor(t *testing.T, s *store.Layout, refSubstring string) ocispec.Descriptor {
t.Helper()
var found ocispec.Descriptor
if err := s.OCI.Walk(func(_ string, desc ocispec.Descriptor) error {
if strings.Contains(desc.Annotations[ocispec.AnnotationRefName], refSubstring) {
found = desc
}
return nil
}); err != nil {
t.Fatalf("walk: %v", err)
}
if found.Digest == "" {
t.Fatalf("no artifact found for ref containing %q", refSubstring)
}
return found
}
// corruptInfoCheckBlob flips the first byte of the blob identified by d, preserving
// its exact length -- a same-length corruption that only a real digest check catches.
func corruptInfoCheckBlob(t *testing.T, root string, d digest.Digest) {
t.Helper()
path := filepath.Join(root, ocispec.ImageBlobsDir, d.Algorithm().String(), d.Hex())
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read blob %s: %v", d, err)
}
corrupted := append([]byte(nil), data...)
corrupted[0] ^= 0xFF
if err := os.WriteFile(path, corrupted, 0o644); err != nil {
t.Fatalf("corrupt blob %s: %v", d, err)
}
}
// captureStdoutAndStderr redirects os.Stdout and os.Stderr for the duration of fn,
// returning everything written to each separately along with fn's return value.
//
// fn must perform any log.NewLogger call *inside* itself (after the swap), since
// pkg/log's zerolog.ConsoleWriter captures the concrete *os.File value of os.Stdout
// at construction time, not a live reference to the os.Stdout variable.
func captureStdoutAndStderr(t *testing.T, fn func() error) (stdout, stderr string, fnErr error) {
t.Helper()
oldStdout, oldStderr := os.Stdout, os.Stderr
outR, outW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe (stdout): %v", err)
}
errR, errW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe (stderr): %v", err)
}
os.Stdout = outW
os.Stderr = errW
fnErr = fn()
outW.Close()
errW.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
var outBuf, errBuf bytes.Buffer
if _, err := io.Copy(&outBuf, outR); err != nil {
t.Fatalf("read captured stdout: %v", err)
}
if _, err := io.Copy(&errBuf, errR); err != nil {
t.Fatalf("read captured stderr: %v", err)
}
outR.Close()
errR.Close()
return outBuf.String(), errBuf.String(), fnErr
}
// TestStoreInfoCheck_JSON_CorruptArtifactInPayload exercises `hauler store info
// --check -o json` through the real cobra wiring. Under the current design, the
// corruption summary/remediation lines go through normal log.FromContext calls
// unconditionally -- no more stderr special-casing -- and are expected to be
// silently suppressed by the existing PreRunE's `--check && -o json` ->
// SetLevel("fatal") gating. The corrupt artifact's presence in the JSON body
// (with a non-empty problems array) is the machine-readable signal instead.
func TestStoreInfoCheck_JSON_CorruptArtifactInPayload(t *testing.T) {
s, host := newInfoCheckTestStore(t)
ctx := context.Background()
seedInfoCheckImage(t, host, "test/corrupt", "v1")
if _, err := s.AddImage(ctx, host+"/test/corrupt:v1", "", true); err != nil {
t.Fatalf("AddImage: %v", err)
}
desc := findInfoCheckManifestDescriptor(t, s, "test/corrupt")
corruptInfoCheckBlob(t, s.Root, desc.Digest)
rso := &flags.StoreRootOpts{StoreDir: s.Root, Retries: 1}
ro := &flags.CliRootOpts{LogLevel: "error", AuditLevel: "none", HaulerDir: t.TempDir()}
cmd := addStoreInfo(rso, ro)
cmd.SetArgs([]string{"--check", "-o", "json"})
stdout, stderr, err := captureStdoutAndStderr(t, func() error {
logger := log.NewLogger(os.Stdout)
execCtx := logger.WithContext(ctx)
return cmd.ExecuteContext(execCtx)
})
if err != nil {
t.Fatalf("execute: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
}
var out struct {
Artifacts []struct {
Reference string `json:"reference"`
Problems []string `json:"problems"`
} `json:"artifacts"`
}
if jerr := json.Unmarshal([]byte(stdout), &out); jerr != nil {
t.Fatalf("stdout is not valid JSON: %v\nstdout: %q\nstderr: %q", jerr, stdout, stderr)
}
if len(out.Artifacts) != 1 {
t.Fatalf("expected exactly one artifact in the failure report, got %d: %+v", len(out.Artifacts), out.Artifacts)
}
if len(out.Artifacts[0].Problems) == 0 {
t.Errorf("expected non-empty problems for the corrupt artifact, got: %+v", out.Artifacts[0])
}
}
// TestStoreInfoCheck_JSON_NoCheckStillWorks exercises `hauler store info -o json`
// (no --check) through the real cobra wiring. It is the regression guard for Fix 2:
// narrowing the json-output log suppression to `--check && -o json` must not break
// (or leave globally suppressed) the plain `-o json` path, which never emitted a
// corruption-summary log in the first place.
func TestStoreInfoCheck_JSON_NoCheckStillWorks(t *testing.T) {
origLevel := zerolog.GlobalLevel()
t.Cleanup(func() { zerolog.SetGlobalLevel(origLevel) })
zerolog.SetGlobalLevel(zerolog.InfoLevel)
s, host := newInfoCheckTestStore(t)
ctx := context.Background()
seedInfoCheckImage(t, host, "test/plain", "v1")
if _, err := s.AddImage(ctx, host+"/test/plain:v1", "", true); err != nil {
t.Fatalf("AddImage: %v", err)
}
rso := &flags.StoreRootOpts{StoreDir: s.Root, Retries: 1}
ro := &flags.CliRootOpts{LogLevel: "error", AuditLevel: "none", HaulerDir: t.TempDir()}
cmd := addStoreInfo(rso, ro)
cmd.SetArgs([]string{"-o", "json"})
stdout, stderr, err := captureStdoutAndStderr(t, func() error {
logger := log.NewLogger(os.Stdout)
execCtx := logger.WithContext(ctx)
return cmd.ExecuteContext(execCtx)
})
if err != nil {
t.Fatalf("execute: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
}
var out map[string]interface{}
if jerr := json.Unmarshal([]byte(stdout), &out); jerr != nil {
t.Fatalf("stdout is not valid JSON: %v\nstdout: %q\nstderr: %q", jerr, stdout, stderr)
}
if zerolog.GlobalLevel() == zerolog.FatalLevel {
t.Error("global log level was clamped to fatal even though --check was not requested")
}
}
+3
View File
@@ -10,6 +10,7 @@ type InfoOpts struct {
SizeUnit string
ListRepos bool
ShowDigests bool
Check bool
}
func (o *InfoOpts) AddFlags(cmd *cobra.Command) {
@@ -19,4 +20,6 @@ func (o *InfoOpts) AddFlags(cmd *cobra.Command) {
f.StringVar(&o.TypeFilter, "type", "all", "(Optional) Filter on content type (image | chart | file | sigs | atts | sbom | referrer)")
f.BoolVar(&o.ListRepos, "list-repos", false, "(Optional) List all repository names")
f.BoolVar(&o.ShowDigests, "digests", false, "(Optional) Show digests of each artifact in the output table")
f.BoolVar(&o.Check, "check", false,
"(Optional) Check the integrity of each artifact by hashing every blob (slow on large stores)")
}
+312
View File
@@ -0,0 +1,312 @@
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
}
+366
View File
@@ -0,0 +1,366 @@
package store_test
import (
"context"
"fmt"
"net/http/httptest"
"os"
"strings"
"testing"
gname "github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
gcrv1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/random"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/static"
gvtypes "github.com/google/go-containerregistry/pkg/v1/types"
digest "github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"hauler.dev/go/hauler/v2/pkg/store"
)
// newCheckTestRegistry starts an in-memory OCI registry for check tests.
func newCheckTestRegistry(t *testing.T) (host string, opts []remote.Option) {
t.Helper()
srv := httptest.NewServer(registry.New())
t.Cleanup(srv.Close)
host = strings.TrimPrefix(srv.URL, "http://")
opts = []remote.Option{remote.WithTransport(srv.Client().Transport)}
return host, opts
}
// newCheckTestStore creates a fresh store.Layout in a temp directory.
func newCheckTestStore(t *testing.T) *store.Layout {
t.Helper()
s, err := store.NewLayout(t.TempDir())
if err != nil {
t.Fatalf("NewLayout: %v", err)
}
return s
}
// pushAndAddImage pushes a random 2-layer image to the test registry under
// host/repo:tag and adds it to s via AddImage, returning the manifest
// descriptor as recorded in the store's index.
func pushAndAddImage(t *testing.T, s *store.Layout, host, repo, tag string, opts []remote.Option) ocispec.Descriptor {
t.Helper()
img, err := random.Image(256, 2)
if err != nil {
t.Fatalf("random.Image: %v", err)
}
return pushAndAddExistingImage(t, s, host, repo, tag, img, opts)
}
// pushAndAddExistingImage pushes img (already constructed) to the test registry
// under host/repo:tag and adds it to s via AddImage, returning the manifest
// descriptor as recorded in the store's index.
func pushAndAddExistingImage(t *testing.T, s *store.Layout, host, repo, tag string, img gcrv1.Image, opts []remote.Option) ocispec.Descriptor {
t.Helper()
ref, err := gname.NewTag(host+"/"+repo+":"+tag, gname.Insecure)
if err != nil {
t.Fatalf("NewTag: %v", err)
}
if err := remote.Write(ref, img, opts...); err != nil {
t.Fatalf("remote.Write: %v", err)
}
if _, err := s.AddImage(context.Background(), ref.Name(), "", true, opts...); err != nil {
t.Fatalf("AddImage: %v", err)
}
return findManifestDescForRef(t, s, repo+":"+tag)
}
// findManifestDescForRef walks the store's index and returns the descriptor whose
// AnnotationRefName contains refSubstr.
func findManifestDescForRef(t *testing.T, s *store.Layout, refSubstr string) ocispec.Descriptor {
t.Helper()
var found ocispec.Descriptor
if err := s.OCI.Walk(func(_ string, desc ocispec.Descriptor) error {
if strings.Contains(desc.Annotations[ocispec.AnnotationRefName], refSubstr) {
found = desc
}
return nil
}); err != nil {
t.Fatalf("walk: %v", err)
}
if found.Digest == "" {
t.Fatalf("no manifest found for ref containing %q", refSubstr)
}
return found
}
// TestCheck_HealthyStore checks that a freshly-populated store with no
// corruption reports every artifact as OK with no problems.
func TestCheck_HealthyStore(t *testing.T) {
s := newCheckTestStore(t)
host, opts := newCheckTestRegistry(t)
desc1 := pushAndAddImage(t, s, host, "test/img1", "v1", opts)
desc2 := pushAndAddImage(t, s, host, "test/img2", "v1", opts)
c := s.NewChecker()
ctx := context.Background()
for _, desc := range []ocispec.Descriptor{desc1, desc2} {
res := c.Check(ctx, desc)
if !res.OK {
t.Errorf("expected healthy artifact %s to check OK, got problems: %+v", desc.Digest, res.Problems)
}
if len(res.Problems) != 0 {
t.Errorf("expected no problems for %s, got %+v", desc.Digest, res.Problems)
}
}
}
// TestCheck_MissingLayerBlob checks that a deleted layer blob file is
// reported as BlobMissing, naming the correct digest.
func TestCheck_MissingLayerBlob(t *testing.T) {
s := newCheckTestStore(t)
host, opts := newCheckTestRegistry(t)
desc := pushAndAddImage(t, s, host, "test/missing", "v1", opts)
manifest := readManifestBlob(t, s.Root, desc.Digest)
if len(manifest.Layers) == 0 {
t.Fatal("expected at least one layer in test image")
}
victim := manifest.Layers[0]
if err := os.Remove(blobPath(s.Root, victim.Digest)); err != nil {
t.Fatalf("remove layer blob: %v", err)
}
c := s.NewChecker()
res := c.Check(context.Background(), desc)
if res.OK {
t.Fatal("expected check to report a problem after deleting a layer blob")
}
var found *store.BlobResult
for i := range res.Problems {
if res.Problems[i].Digest == victim.Digest.String() {
found = &res.Problems[i]
}
}
if found == nil {
t.Fatalf("expected a problem for digest %s, got %+v", victim.Digest, res.Problems)
}
if found.Status != store.BlobMissing {
t.Errorf("status = %q, want %q", found.Status, store.BlobMissing)
}
}
// TestCheckBlob_SizeMismatchShortCircuits proves that CheckBlob detects a
// truncated (shorter) blob via its size alone, without ever hashing the
// content: HashCount for the digest must remain 0.
func TestCheckBlob_SizeMismatchShortCircuits(t *testing.T) {
s := newCheckTestStore(t)
host, opts := newCheckTestRegistry(t)
desc := pushAndAddImage(t, s, host, "test/truncated", "v1", opts)
manifest := readManifestBlob(t, s.Root, desc.Digest)
victim := manifest.Layers[0]
path := blobPath(s.Root, victim.Digest)
orig, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read original blob: %v", err)
}
half := orig[:len(orig)/2]
if err := os.WriteFile(path, half, 0o644); err != nil {
t.Fatalf("truncate blob: %v", err)
}
c := s.NewChecker()
res := c.CheckBlob(ocispec.Descriptor{Digest: victim.Digest, Size: victim.Size})
if res.Status != store.BlobSizeMismatch {
t.Fatalf("status = %q, want %q (detail: %s)", res.Status, store.BlobSizeMismatch, res.Detail)
}
wantDetail := fmt.Sprintf("expected %d bytes, found %d", victim.Size, len(half))
if res.Detail != wantDetail {
t.Errorf("detail = %q, want %q", res.Detail, wantDetail)
}
// Prove the check never reached the hashing step: a size mismatch is
// detected via os.Stat alone, before the file's content is ever streamed
// through the digest verifier.
if got := c.HashCount(victim.Digest.String()); got != 0 {
t.Errorf("HashCount = %d, want 0 (size mismatch must short-circuit before hashing)", got)
}
}
// TestCheckBlob_SameLengthCorruptionIsDigestMismatch is the most important test
// in this file: it proves the checker performs real content hashing rather than
// merely stat-ing the file. A same-length, in-place byte flip preserves file size,
// so only a genuine digest check (not a size check) can catch it.
func TestCheckBlob_SameLengthCorruptionIsDigestMismatch(t *testing.T) {
s := newCheckTestStore(t)
host, opts := newCheckTestRegistry(t)
desc := pushAndAddImage(t, s, host, "test/corrupt", "v1", opts)
manifest := readManifestBlob(t, s.Root, desc.Digest)
victim := manifest.Layers[0]
path := blobPath(s.Root, victim.Digest)
orig, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read original blob: %v", err)
}
corrupted := append([]byte(nil), orig...)
// Flip a byte at a fixed offset, preserving the exact file length.
corrupted[0] ^= 0xFF
if err := os.WriteFile(path, corrupted, 0o644); err != nil {
t.Fatalf("corrupt blob: %v", err)
}
if len(corrupted) != len(orig) {
t.Fatalf("test setup bug: corrupted length %d != original length %d", len(corrupted), len(orig))
}
c := s.NewChecker()
res := c.CheckBlob(ocispec.Descriptor{Digest: victim.Digest, Size: victim.Size})
if res.Status != store.BlobDigestMismatch {
t.Fatalf("status = %q, want %q (detail: %s) -- a size-only check would incorrectly pass this same-length corruption",
res.Status, store.BlobDigestMismatch, res.Detail)
}
// This corruption necessarily requires the content to have actually been hashed.
if got := c.HashCount(victim.Digest.String()); got != 1 {
t.Errorf("HashCount = %d, want 1", got)
}
}
// TestCheck_CorruptManifestStopsDescending checks that when the manifest
// blob itself fails its digest check, Check reports exactly that one
// problem and does not attempt to check (or hash) the config/layer blobs it
// references, since a manifest that fails its own digest check cannot be
// trusted to accurately name its children.
func TestCheck_CorruptManifestStopsDescending(t *testing.T) {
s := newCheckTestStore(t)
host, opts := newCheckTestRegistry(t)
desc := pushAndAddImage(t, s, host, "test/badmanifest", "v1", opts)
manifest := readManifestBlob(t, s.Root, desc.Digest)
if len(manifest.Layers) == 0 {
t.Fatal("expected at least one layer")
}
manifestPath := blobPath(s.Root, desc.Digest)
orig, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("read manifest blob: %v", err)
}
corrupted := append([]byte(nil), orig...)
corrupted[0] ^= 0xFF
if err := os.WriteFile(manifestPath, corrupted, 0o644); err != nil {
t.Fatalf("corrupt manifest blob: %v", err)
}
if len(corrupted) != len(orig) {
t.Fatalf("test setup bug: corrupted length %d != original length %d", len(corrupted), len(orig))
}
c := s.NewChecker()
res := c.Check(context.Background(), desc)
if res.OK {
t.Fatal("expected check to fail for a corrupted manifest blob")
}
if len(res.Problems) != 1 {
t.Fatalf("expected exactly one problem (the manifest itself), got %d: %+v", len(res.Problems), res.Problems)
}
if res.Problems[0].Digest != desc.Digest.String() {
t.Errorf("problem digest = %q, want %q (the manifest itself)", res.Problems[0].Digest, desc.Digest)
}
if res.Problems[0].Status != store.BlobDigestMismatch {
t.Errorf("problem status = %q, want %q", res.Problems[0].Status, store.BlobDigestMismatch)
}
// Prove recursion truly stopped: none of the config/layer digests the
// (untrustworthy) manifest names were ever hashed.
if got := c.HashCount(manifest.Config.Digest.String()); got != 0 {
t.Errorf("config HashCount = %d, want 0 (must not descend into a manifest that failed its own check)", got)
}
for _, l := range manifest.Layers {
if got := c.HashCount(l.Digest.String()); got != 0 {
t.Errorf("layer %s HashCount = %d, want 0", l.Digest, got)
}
}
}
// TestCheck_SharedLayerMemoizedAcrossImages checks that when two images
// share a common layer, corrupting that shared blob is reported by both
// images' Check results, and that the shared blob is only ever hashed once
// across the whole run (proven via the Checker's per-digest memo).
func TestCheck_SharedLayerMemoizedAcrossImages(t *testing.T) {
s := newCheckTestStore(t)
host, opts := newCheckTestRegistry(t)
sharedData := []byte(strings.Repeat("shared-layer-content", 100))
sharedLayer := static.NewLayer(sharedData, gvtypes.OCILayer)
uniqueLayer1, err := random.Layer(128, gvtypes.OCILayer)
if err != nil {
t.Fatalf("random.Layer 1: %v", err)
}
uniqueLayer2, err := random.Layer(128, gvtypes.OCILayer)
if err != nil {
t.Fatalf("random.Layer 2: %v", err)
}
img1, err := mutate.AppendLayers(empty.Image, sharedLayer, uniqueLayer1)
if err != nil {
t.Fatalf("build img1: %v", err)
}
img2, err := mutate.AppendLayers(empty.Image, sharedLayer, uniqueLayer2)
if err != nil {
t.Fatalf("build img2: %v", err)
}
desc1 := pushAndAddExistingImage(t, s, host, "test/shared1", "v1", img1, opts)
desc2 := pushAndAddExistingImage(t, s, host, "test/shared2", "v1", img2, opts)
sharedDigest, err := sharedLayer.Digest()
if err != nil {
t.Fatalf("shared layer digest: %v", err)
}
// Corrupt the shared blob in place, preserving its length.
path := blobPath(s.Root, digest.Digest(sharedDigest.String()))
orig, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read shared blob: %v", err)
}
corrupted := append([]byte(nil), orig...)
corrupted[0] ^= 0xFF
if err := os.WriteFile(path, corrupted, 0o644); err != nil {
t.Fatalf("corrupt shared blob: %v", err)
}
c := s.NewChecker()
ctx := context.Background()
res1 := c.Check(ctx, desc1)
res2 := c.Check(ctx, desc2)
for name, res := range map[string]store.CheckResult{"img1": res1, "img2": res2} {
if res.OK {
t.Errorf("%s: expected check to report the shared blob corruption", name)
}
found := false
for _, p := range res.Problems {
if p.Digest == sharedDigest.String() {
found = true
}
}
if !found {
t.Errorf("%s: expected a problem for shared digest %s, got %+v", name, sharedDigest, res.Problems)
}
}
if got := c.HashCount(sharedDigest.String()); got != 1 {
t.Errorf("shared layer HashCount = %d, want 1 (memoization must prevent re-hashing across images)", got)
}
}