mirror of
https://github.com/hauler-dev/hauler.git
synced 2026-08-19 20:36:30 +00:00
feat: add blob integrity check to info command (#699)
Signed-off-by: Adam Martin <adam.martin@ranchergovernment.com>
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user