From b178b3fa8193cbeaaa8eab68be199aa7eff0c194 Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Wed, 5 Aug 2026 10:13:12 -0400 Subject: [PATCH] feat: add blob integrity check to info command (#699) Signed-off-by: Adam Martin --- cmd/hauler/cli/store.go | 12 + cmd/hauler/cli/store/info.go | 505 +++++++++++++++--- cmd/hauler/cli/store/info_test.go | 652 +++++++++++++++++++++++- cmd/hauler/cli/store_info_check_test.go | 244 +++++++++ internal/flags/info.go | 3 + pkg/store/check.go | 312 ++++++++++++ pkg/store/check_test.go | 366 +++++++++++++ 7 files changed, 2027 insertions(+), 67 deletions(-) create mode 100644 cmd/hauler/cli/store_info_check_test.go create mode 100644 pkg/store/check.go create mode 100644 pkg/store/check_test.go diff --git a/cmd/hauler/cli/store.go b/cmd/hauler/cli/store.go index 174f72c..8555243 100644 --- a/cmd/hauler/cli/store.go +++ b/cmd/hauler/cli/store.go @@ -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() diff --git a/cmd/hauler/cli/store/info.go b/cmd/hauler/cli/store/info.go index 8fed740..b896293 100644 --- a/cmd/hauler/cli/store/info.go +++ b/cmd/hauler/cli/store/info.go @@ -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:" 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 { diff --git a/cmd/hauler/cli/store/info_test.go b/cmd/hauler/cli/store/info_test.go index 092de2b..39dc428 100644 --- a/cmd/hauler/cli/store/info_test.go +++ b/cmd/hauler/cli/store/info_test.go @@ -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 ` 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) + } + } +} diff --git a/cmd/hauler/cli/store_info_check_test.go b/cmd/hauler/cli/store_info_check_test.go new file mode 100644 index 0000000..c75e20d --- /dev/null +++ b/cmd/hauler/cli/store_info_check_test.go @@ -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") + } +} diff --git a/internal/flags/info.go b/internal/flags/info.go index bc3ac42..20e9722 100644 --- a/internal/flags/info.go +++ b/internal/flags/info.go @@ -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)") } diff --git a/pkg/store/check.go b/pkg/store/check.go new file mode 100644 index 0000000..7755bb8 --- /dev/null +++ b/pkg/store/check.go @@ -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 +// /blobs//. +// 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 +} diff --git a/pkg/store/check_test.go b/pkg/store/check_test.go new file mode 100644 index 0000000..875853c --- /dev/null +++ b/pkg/store/check_test.go @@ -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) + } +}