From 0c80686db39868f683115b76bef1ed0b3fe3a06e Mon Sep 17 00:00:00 2001 From: Matthew Burket Date: Mon, 18 May 2026 11:03:12 -0500 Subject: [PATCH] scan: improve functionality with locally saved images Previously the locally saved image flow was at best confusing and at worst just wrong for some cases. This change simplified the handling of local images and reduces resources that were previously allocated for no good reason. Tests were also added to verify both docker and podman saved images work as expected. The application now expects all saved images to follow the OCI spec https://github.com/opencontainers/image-spec/blob/v1.1.1/image-layout.md This code was backported by Claude Code. Co-authored-by: crozzy --- cmd/cvetool/scan.go | 24 +-- go.mod | 4 +- go.sum | 4 +- image/docker.go | 172 ------------------ image/docker_test.go | 28 --- image/filesystem.go | 43 +---- image/inspect.go | 130 -------------- image/manifest.go | 293 +++++++++++++++++++++++++++++++ image/manifest_test.go | 115 ++++++++++++ image/testdata/docker_save.txtar | 35 ++++ image/testdata/podman_save.txtar | 33 ++++ 11 files changed, 502 insertions(+), 379 deletions(-) delete mode 100644 image/docker.go delete mode 100644 image/docker_test.go delete mode 100644 image/inspect.go create mode 100644 image/manifest.go create mode 100644 image/manifest_test.go create mode 100644 image/testdata/docker_save.txtar create mode 100644 image/testdata/podman_save.txtar diff --git a/cmd/cvetool/scan.go b/cmd/cvetool/scan.go index 7bf0f83..fd0d83a 100644 --- a/cmd/cvetool/scan.go +++ b/cmd/cvetool/scan.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/quay/claircore" "github.com/quay/claircore/enricher/cvss" "github.com/quay/claircore/indexer" "github.com/quay/claircore/libindex" @@ -127,8 +128,8 @@ func scan(c *cli.Context) error { ) var ( - img image.Image - fa indexer.FetchArena + mf *claircore.Manifest + fa indexer.FetchArena ) switch { case imgRef != "": @@ -138,24 +139,32 @@ func scan(c *cli.Context) error { if err != nil { return fmt.Errorf("error setting DOCKER_CONFIG env var") } - img = image.NewDockerRemoteImage(ctx, imgRef) + mf, err = image.ManifestFromRemote(ctx, imgRef) + if err != nil { + return fmt.Errorf("error getting image information: %v", err) + } case imgPath != "": fa = &LocalFetchArena{} var err error - img, err = image.NewDockerLocalImage(ctx, imgPath, os.TempDir()) + mf, err = image.ManifestFromLocal(ctx, imgPath) if err != nil { return fmt.Errorf("error getting image information: %v", err) } case rootPath != "": fa = &LocalFetchArena{} var err error - img, err = image.NewFileSystemImage(ctx, rootPath) + mf, err = image.ManifestFromFilesystem(ctx, rootPath) if err != nil { return fmt.Errorf("error getting filesystem information: %v", err) } default: return fmt.Errorf("no --image-path ($IMAGE_PATH), --image-ref ($IMAGE_REF) or --root-path ($ROOT_PATH) set") } + defer func() { + for _, l := range mf.Layers { + l.Close() + } + }() switch { case dbPath != "": @@ -204,11 +213,6 @@ func scan(c *cli.Context) error { return fmt.Errorf("error creating Libvuln: %v", err) } - mf, err := img.GetManifest(ctx) - if err != nil { - return fmt.Errorf("error creating manifest: %v", err) - } - indexerOpts := &libindex.Options{ Store: datastore.NewLocalIndexerStore(), Locker: NewLocalLockSource(), diff --git a/go.mod b/go.mod index 5aee07e..7a89a3a 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,8 @@ require ( github.com/remind101/migrate v0.0.0-20170729031349-52c1edff7319 github.com/rs/zerolog v1.35.1 github.com/urfave/cli/v2 v2.27.7 - modernc.org/sqlite v1.50.1 + golang.org/x/tools v0.44.0 + modernc.org/sqlite v1.50.0 ) require ( @@ -70,7 +71,6 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gotest.tools/v3 v3.5.2 // indirect modernc.org/libc v1.72.3 // indirect diff --git a/go.sum b/go.sum index 10d8175..1945b04 100644 --- a/go.sum +++ b/go.sum @@ -226,8 +226,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= -modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/image/docker.go b/image/docker.go deleted file mode 100644 index d3e26ff..0000000 --- a/image/docker.go +++ /dev/null @@ -1,172 +0,0 @@ -package image - -import ( - "archive/tar" - "context" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/quay/claircore" - "github.com/quay/zlog" -) - -var _ Image = (*dockerLocalImage)(nil) - -type Image interface { - GetManifest(context.Context) (*claircore.Manifest, error) -} - -type imageInfo struct { - Config string `json:"Config"` - Layers []string `json:"Layers"` -} - -type dockerLocalImage struct { - imageDigest string - layerPaths []string -} - -func NewDockerLocalImage(ctx context.Context, exportDir string, importDir string) (*dockerLocalImage, error) { - f, err := os.Open(exportDir) - if err != nil { - return nil, fmt.Errorf("unable to open tar: %w", err) - } - - di := &dockerLocalImage{} - m := &imageInfo{} - - tr := tar.NewReader(f) - hdr, err := tr.Next() - for ; err == nil; hdr, err = tr.Next() { - dir, fn := filepath.Split(hdr.Name) - - if strings.HasSuffix(fn, ".tar") { - layerFilePath := "" - - if fn == "layer.tar" { - if hdr.Linkname == "" && hdr.Size > 0 { - sha := filepath.Base(dir) - layerFilePath = filepath.Join(importDir, "sha256:"+sha) - } else { - continue - } - } else { - sha := strings.TrimSuffix(fn, filepath.Ext(fn)) - layerFilePath = filepath.Join(importDir, "sha256:"+sha) - } - - zlog.Debug(ctx).Str("layerFilePath", layerFilePath).Msg("found .tar file") - - layerFile, err := os.OpenFile(layerFilePath, os.O_CREATE|os.O_RDWR, os.FileMode(0600)) - if err != nil { - return nil, err - } - _, err = io.Copy(layerFile, tr) - if err != nil { - return nil, err - } - di.layerPaths = append(di.layerPaths, layerFile.Name()) - layerFile.Close() - } - - if fn == "manifest.json" { - _m := []*imageInfo{} - b, err := io.ReadAll(tr) - if err != nil { - return nil, err - } - err = json.Unmarshal(b, &_m) - if err != nil { - return nil, err - } - m = _m[0] - digest := strings.TrimSuffix(m.Config, filepath.Ext(m.Config)) - zlog.Debug(ctx).Str("digest", digest) - di.imageDigest = "sha256:" + digest - } - } - - var sortedPaths []string - zlog.Debug(ctx).Any("m.Layers", m.Layers) - zlog.Debug(ctx).Any("di.layerPaths", di.layerPaths) - - for _, p := range m.Layers { - zlog.Debug(ctx).Str("p", p) - for _, l := range di.layerPaths { - zlog.Debug(ctx).Str("p", p).Str("l", l).Msg("lps") - if filepath.Dir(p) == strings.TrimPrefix(filepath.Base(l), "sha256:") { - sortedPaths = append(sortedPaths, l) - } - if strings.TrimSuffix(p, filepath.Ext(p)) == strings.TrimPrefix(filepath.Base(l), "sha256:") { - sortedPaths = append(sortedPaths, l) - } - } - } - zlog.Debug(ctx).Any("sortedPaths", sortedPaths).Msg("layers") - di.layerPaths = sortedPaths - return di, nil -} - -func (i *dockerLocalImage) getLayers(ctx context.Context) ([]*claircore.Layer, error) { - if len(i.layerPaths) == 0 { - return nil, nil - } - layers := []*claircore.Layer{} - for _, layerStr := range i.layerPaths { - _, d := filepath.Split(layerStr) - - desc := &claircore.LayerDescription{ - Digest: d, - URI: layerStr, - MediaType: "application/vnd.oci.image.layer.v1.tar", - } - - l := &claircore.Layer{} - f, err := os.OpenFile(layerStr, os.O_RDONLY, os.FileMode(0600)) - if err != nil { - zlog.Error(ctx).Err(err) - } - err = l.Init(ctx, desc, f) - if err != nil { - zlog.Error(ctx).Err(err) - } - - layers = append(layers, l) - - l.Close() - } - return layers, nil -} - -func (i *dockerLocalImage) GetManifest(ctx context.Context) (*claircore.Manifest, error) { - digest, err := claircore.ParseDigest(i.imageDigest) - if err != nil { - return nil, err - } - - layers, err := i.getLayers(ctx) - if err != nil { - return nil, err - } - - return &claircore.Manifest{ - Hash: digest, - Layers: layers, - }, nil -} - -type dockerRemoteImage struct { - ref string -} - -func NewDockerRemoteImage(ctx context.Context, imgRef string) *dockerRemoteImage { - return &dockerRemoteImage{ref: imgRef} -} - -func (i *dockerRemoteImage) GetManifest(ctx context.Context) (*claircore.Manifest, error) { - return Inspect(ctx, i.ref) -} diff --git a/image/docker_test.go b/image/docker_test.go deleted file mode 100644 index 920de13..0000000 --- a/image/docker_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package image - -import ( - "context" - "fmt" - "os" - "testing" -) - -func setup(resultsDir string) { - os.Mkdir(resultsDir, 0700) -} - -func teardown(resultsDir string) { - os.RemoveAll(resultsDir) -} - -func TestFromExported(t *testing.T) { - resultsDir := "testdata/results" - setup(resultsDir) - defer teardown(resultsDir) - ctx := context.TODO() - di, err := NewDockerLocalImage(ctx, "testdata/algo", resultsDir) - if err != nil { - t.Fatalf("got error %v", err) - } - fmt.Println(di) -} diff --git a/image/filesystem.go b/image/filesystem.go index 8e19d16..d5df221 100644 --- a/image/filesystem.go +++ b/image/filesystem.go @@ -8,54 +8,27 @@ import ( "github.com/quay/claircore" ) -type fileSystemImage struct { - imageDigest string - layerPaths []string - rootDir string -} - -func NewFileSystemImage(ctx context.Context, rootDir string) (*fileSystemImage, error) { - fsi := &fileSystemImage{} - fsi.rootDir = rootDir - return fsi, nil -} - -func (i *fileSystemImage) getLayers(ctx context.Context) ([]*claircore.Layer, error) { - layers := []*claircore.Layer{} +func ManifestFromFilesystem(ctx context.Context, rootDir string) (*claircore.Manifest, error) { + digest, err := claircore.ParseDigest(fmt.Sprintf("sha256:%s", strings.Repeat("0", 64))) + if err != nil { + return nil, err + } desc := &claircore.LayerDescription{ Digest: fmt.Sprintf("sha256:%s", strings.Repeat("1", 64)), - URI: "file://" + i.rootDir, + URI: "file://" + rootDir, MediaType: "application/vnd.claircore.filesystem", } l := &claircore.Layer{} - err := l.Init(ctx, desc, nil) - + err = l.Init(ctx, desc, nil) if err != nil { return nil, err } - l.Close() - layers = append(layers, l) - - return layers, nil -} - -func (i *fileSystemImage) GetManifest(ctx context.Context) (*claircore.Manifest, error) { - digest, err := claircore.ParseDigest(fmt.Sprintf("sha256:%s", strings.Repeat("0", 64))) - if err != nil { - return nil, err - } - - layers, err := i.getLayers(ctx) - if err != nil { - return nil, err - } - return &claircore.Manifest{ Hash: digest, - Layers: layers, + Layers: []*claircore.Layer{l}, }, nil } diff --git a/image/inspect.go b/image/inspect.go deleted file mode 100644 index 4e5351a..0000000 --- a/image/inspect.go +++ /dev/null @@ -1,130 +0,0 @@ -// This is lifted from Clairctl - -package image - -import ( - "context" - "net/http" - "net/url" - "path" - "strings" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/remote/transport" - - "github.com/quay/claircore" - "github.com/quay/zlog" -) - -const ( - userAgent = `clair-action/1` -) - -func rt(ctx context.Context, ref string) (http.RoundTripper, error) { - r, err := name.ParseReference(ref) - if err != nil { - return nil, err - } - repo := r.Context() - - auth, err := authn.DefaultKeychain.Resolve(repo) - if err != nil { - return nil, err - } - rt := http.DefaultTransport - rt = transport.NewUserAgent(rt, userAgent) - rt = transport.NewRetry(rt) - rt, err = transport.NewWithContext(ctx, repo.Registry, auth, rt, []string{repo.Scope(transport.PullScope)}) - if err != nil { - return nil, err - } - return rt, nil -} - -func Inspect(ctx context.Context, r string) (*claircore.Manifest, error) { - rt, err := rt(ctx, r) - if err != nil { - return nil, err - } - - ref, err := name.ParseReference(r) - if err != nil { - return nil, err - } - desc, err := remote.Get(ref, remote.WithTransport(rt)) - if err != nil { - return nil, err - } - img, err := desc.Image() - if err != nil { - return nil, err - } - dig, err := img.Digest() - if err != nil { - return nil, err - } - ccd, err := claircore.ParseDigest(dig.String()) - if err != nil { - return nil, err - } - out := claircore.Manifest{Hash: ccd} - zlog.Debug(ctx). - Str("ref", r). - Stringer("digest", ccd). - Msg("found manifest") - - ls, err := img.Layers() - if err != nil { - return nil, err - } - zlog.Debug(ctx). - Str("ref", r). - Int("count", len(ls)). - Msg("found layers") - - repo := ref.Context() - rURL := url.URL{ - Scheme: repo.Scheme(), - Host: repo.RegistryStr(), - } - c := http.Client{ - Transport: rt, - } - - for _, l := range ls { - d, err := l.Digest() - if err != nil { - return nil, err - } - ccd, err := claircore.ParseDigest(d.String()) - if err != nil { - return nil, err - } - u, err := rURL.Parse(path.Join("/", "v2", strings.TrimPrefix(repo.RepositoryStr(), repo.RegistryStr()), "blobs", d.String())) - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Add("Range", "bytes=0-0") - res, err := c.Do(req) - if err != nil { - return nil, err - } - res.Body.Close() - - res.Request.Header.Del("User-Agent") - res.Request.Header.Del("Range") - out.Layers = append(out.Layers, &claircore.Layer{ - Hash: ccd, - URI: res.Request.URL.String(), - Headers: res.Request.Header, - }) - } - - return &out, nil -} diff --git a/image/manifest.go b/image/manifest.go new file mode 100644 index 0000000..602ab55 --- /dev/null +++ b/image/manifest.go @@ -0,0 +1,293 @@ +// This is lifted from Clairctl + +package image + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" + + "github.com/quay/claircore" + "github.com/quay/claircore/pkg/tarfs" + "github.com/quay/zlog" +) + +const ( + userAgent = `cvetool/1` +) + +func rt(ctx context.Context, ref string) (http.RoundTripper, error) { + r, err := name.ParseReference(ref) + if err != nil { + return nil, err + } + repo := r.Context() + + auth, err := authn.DefaultKeychain.Resolve(repo) + if err != nil { + return nil, err + } + rt := http.DefaultTransport + rt = transport.NewUserAgent(rt, userAgent) + rt = transport.NewRetry(rt) + rt, err = transport.NewWithContext(ctx, repo.Registry, auth, rt, []string{repo.Scope(transport.PullScope)}) + if err != nil { + return nil, err + } + return rt, nil +} + +func ManifestFromRemote(ctx context.Context, r string) (*claircore.Manifest, error) { + rt, err := rt(ctx, r) + if err != nil { + return nil, err + } + + ref, err := name.ParseReference(r) + if err != nil { + return nil, err + } + desc, err := remote.Get(ref, remote.WithTransport(rt)) + if err != nil { + return nil, err + } + img, err := desc.Image() + if err != nil { + return nil, err + } + dig, err := img.Digest() + if err != nil { + return nil, err + } + ccd, err := claircore.ParseDigest(dig.String()) + if err != nil { + return nil, err + } + out := claircore.Manifest{Hash: ccd} + zlog.Debug(ctx). + Str("ref", r). + Stringer("digest", ccd). + Msg("found manifest") + + ls, err := img.Layers() + if err != nil { + return nil, err + } + zlog.Debug(ctx). + Str("ref", r). + Int("count", len(ls)). + Msg("found layers") + + repo := ref.Context() + rURL := url.URL{ + Scheme: repo.Scheme(), + Host: repo.RegistryStr(), + } + c := http.Client{ + Transport: rt, + } + + for _, l := range ls { + d, err := l.Digest() + if err != nil { + return nil, err + } + ccd, err := claircore.ParseDigest(d.String()) + if err != nil { + return nil, err + } + u, err := rURL.Parse(path.Join("/", "v2", strings.TrimPrefix(repo.RepositoryStr(), repo.RegistryStr()), "blobs", d.String())) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Add("Range", "bytes=0-0") + res, err := c.Do(req) + if err != nil { + return nil, err + } + res.Body.Close() + + res.Request.Header.Del("User-Agent") + res.Request.Header.Del("Range") + out.Layers = append(out.Layers, &claircore.Layer{ + Hash: ccd, + URI: res.Request.URL.String(), + Headers: res.Request.Header, + }) + } + + return &out, nil +} + +type indexFile struct { + Manifests []manifestInfo `json:"manifests"` +} + +type manifestInfo struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +type manifestFile struct { + Layers []layerInfo `json:"layers"` +} + +type layerInfo struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +func ManifestFromLocal(ctx context.Context, exportDir string) (*claircore.Manifest, error) { + f, err := os.Open(exportDir) + if err != nil { + return nil, fmt.Errorf("unable to open tar: %w", err) + } + defer f.Close() + + out := &claircore.Manifest{} + m := &manifestFile{} + i := &indexFile{} + fs, err := tarfs.New(f) + if err != nil { + return nil, fmt.Errorf("unable to create tarfs: %w", err) + } + index, err := fs.Open("index.json") + if err != nil { + return nil, fmt.Errorf("unable to open index.json: %w", err) + } + defer index.Close() + b, err := io.ReadAll(index) + if err != nil { + return nil, fmt.Errorf("unable to read index.json: %w", err) + } + err = json.Unmarshal(b, &i) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal index.json: %w", err) + } + manifestDigest := "" + for _, m := range i.Manifests { + if m.MediaType == "application/vnd.oci.image.manifest.v1+json" { + manifestDigest = m.Digest + break + } + } + if manifestDigest == "" { + return nil, fmt.Errorf("manifest digest not found") + } + md, err := claircore.ParseDigest(manifestDigest) + if err != nil { + return nil, fmt.Errorf("unable to parse manifest digest: %w", err) + } + out.Hash = md + + mdb := make([]byte, hex.EncodedLen(len(md.Checksum()))) + hex.Encode(mdb, md.Checksum()) + manifestPath := filepath.Join("blobs", md.Algorithm(), string(mdb)) + manifest, err := fs.Open(manifestPath) + if err != nil { + return nil, fmt.Errorf("unable to open manifest: %w", err) + } + defer manifest.Close() + + b, err = io.ReadAll(manifest) + if err != nil { + return nil, fmt.Errorf("unable to read manifest: %w", err) + } + err = json.Unmarshal(b, &m) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal manifest: %w", err) + } + + // We have to revert to tar.NewReader() because tarfs.New() doesn't support + // seeking. + f.Seek(0, io.SeekStart) + tr := tar.NewReader(f) + out.Layers = make([]*claircore.Layer, len(m.Layers)) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("unable to read layer: %w", err) + } + start, _ := f.Seek(0, io.SeekCurrent) + for i, l := range m.Layers { + ld, err := claircore.ParseDigest(l.Digest) + if err != nil { + return nil, fmt.Errorf("unable to parse layer digest: %w", err) + } + + ldb := make([]byte, hex.EncodedLen(len(ld.Checksum()))) + hex.Encode(ldb, ld.Checksum()) + if hdr.Name == filepath.Join("blobs", ld.Algorithm(), string(ldb)) { + ra := io.NewSectionReader(f, start, hdr.Size) + var rAt io.ReaderAt + switch l.MediaType { + case "application/vnd.oci.image.layer.v1.tar+gzip", "application/vnd.docker.image.rootfs.diff.tar.gzip": + gr, err := gzip.NewReader(ra) + if err != nil { + return nil, fmt.Errorf("unable to create gzip reader: %w", err) + } + tmp, err := os.CreateTemp("", "layer-*.tar") + if err != nil { + return nil, fmt.Errorf("unable to create temp file: %w", err) + } + if _, err := io.Copy(tmp, gr); err != nil { + return nil, fmt.Errorf("unable to decompress layer: %w", err) + } + if err := gr.Close(); err != nil { + return nil, fmt.Errorf("unable to close gzip reader: %w", err) + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("unable to rewind temp file: %w", err) + } + os.Remove(tmp.Name()) + rAt = tmp + case "application/vnd.oci.image.layer.v1.tar", "application/vnd.docker.image.rootfs.diff.tar": + // uncompressed tar, use section directly + rAt = ra + default: + return nil, fmt.Errorf("unsupported layer media type: %s", l.MediaType) + } + layer := &claircore.Layer{Hash: ld} + err = layer.Init(context.Background(), &claircore.LayerDescription{ + Digest: ld.String(), + MediaType: l.MediaType, + }, rAt) + if err != nil { + return nil, fmt.Errorf("unable to initialize layer: %w", err) + } + out.Layers[i] = layer + } + } + } + for i, l := range out.Layers { + if l == nil { + return nil, fmt.Errorf("layer %d (%s) not found in tar", i, m.Layers[i].Digest) + } + } + return out, nil + +} diff --git a/image/manifest_test.go b/image/manifest_test.go new file mode 100644 index 0000000..4395e87 --- /dev/null +++ b/image/manifest_test.go @@ -0,0 +1,115 @@ +package image + +import ( + "archive/tar" + "bytes" + "context" + "encoding/base64" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/tools/txtar" +) + +// writeTarFromTxtar converts a txtar-like archive into a tar file on disk. +func writeTarFromTxtar(t *testing.T, txtarPath string) string { + t.Helper() + b, err := os.ReadFile(txtarPath) + if err != nil { + t.Fatalf("read txtar: %v", err) + } + ar := txtar.Parse(b) + + tmpTar := filepath.Join(t.TempDir(), "image-save.tar") + tf, err := os.Create(tmpTar) + if err != nil { + t.Fatalf("create tar: %v", err) + } + defer tf.Close() + tw := tar.NewWriter(tf) + for _, fe := range ar.Files { + if fe.Name == "" { + t.Fatalf("empty file name in txtar") + } + name := fe.Name + data := fe.Data + if strings.HasSuffix(name, ".b64") { + decoded, err := base64.StdEncoding.DecodeString(string(data)) + if err != nil { + t.Fatalf("base64 decode %s: %v", name, err) + } + data = decoded + name = strings.TrimSuffix(name, ".b64") + } + + h := &tar.Header{ + Name: name, + Mode: 0600, + Size: int64(len(data)), + } + if err := tw.WriteHeader(h); err != nil { + t.Fatalf("write header %s: %v", name, err) + } + if _, err := io.Copy(tw, bytes.NewReader(data)); err != nil { + t.Fatalf("write contents %s: %v", name, err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + return tmpTar +} + +func TestLocalManifest(t *testing.T) { + t.Parallel() + tests := []struct { + name string + txtarRelPath string + wantManifestHash string + wantLayerHash string + }{ + { + name: "docker save", + txtarRelPath: "testdata/docker_save.txtar", + wantManifestHash: "sha256:c9b978d8d0fa53a27117f46b2e17ce906a9de863df82d7709e73868a4932f750", + wantLayerHash: "sha256:e7328e803158cca63d8efdbe1caefb1b51654de77e5fa8691079ad06db1abf75", + }, + { + name: "podman save", + txtarRelPath: "testdata/podman_save.txtar", + wantManifestHash: "sha256:869d3637f2f9b10c265e4bab4b0eccfe8770520e6e903e7dd8acf33b4987bfc1", + wantLayerHash: "sha256:3c6d585e6a72780f0632d16bb8bfd98dfc35b403a11f5cd61925ec31643a76d3", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + exportTar := writeTarFromTxtar(t, tt.txtarRelPath) + ctx := context.Background() + m, err := ManifestFromLocal(ctx, exportTar) + if err != nil { + t.Fatalf("InspectLocal error: %v", err) + } + if m.Hash.String() != tt.wantManifestHash { + t.Fatalf("manifest hash = %s, want %s", m.Hash.String(), tt.wantManifestHash) + } + if len(m.Layers) == 0 { + t.Fatalf("no layers parsed") + } + found := false + for _, l := range m.Layers { + if l.Hash.String() == tt.wantLayerHash { + found = true + break + } + } + if !found { + t.Fatalf("expected layer hash %s not found in layers", tt.wantLayerHash) + } + }) + } +} diff --git a/image/testdata/docker_save.txtar b/image/testdata/docker_save.txtar new file mode 100644 index 0000000..c271d91 --- /dev/null +++ b/image/testdata/docker_save.txtar @@ -0,0 +1,35 @@ +-- index.json -- +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:c9b978d8d0fa53a27117f46b2e17ce906a9de863df82d7709e73868a4932f750", + "size": 403, + "annotations": { + "io.containerd.image.name": "registry.access.redhat.com/ubi9:latest", + "org.opencontainers.image.ref.name": "latest" + } + } + ] +} +-- blobs/sha256/c9b978d8d0fa53a27117f46b2e17ce906a9de863df82d7709e73868a4932f750 -- +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:not_used", + "size": 6421 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "size": 220809728, + "digest": "sha256:e7328e803158cca63d8efdbe1caefb1b51654de77e5fa8691079ad06db1abf75" + } + ] +} +-- blobs/sha256/e7328e803158cca63d8efdbe1caefb1b51654de77e5fa8691079ad06db1abf75.b64 -- +Li8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA3NTUAMDAwMTc1MAAwMDAxNzUwADAwMDAwMDAwMDAwADE1MTExMzU3MDI1ADAxMDY3NQAgNQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhciAgAGNyb3p6eQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY3Jvenp5AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAuL2FsZ28udHh0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDAwMDY0NAAwMDAxNzUwADAwMDE3NTAAMDAwMDAwMDAwMDUAMTUxMTEzNTY3NjAAMDEyMzYwACAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVzdGFyICAAY3Jvenp5AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABjcm96enkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGFsZ28KAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== diff --git a/image/testdata/podman_save.txtar b/image/testdata/podman_save.txtar new file mode 100644 index 0000000..2ae57f5 --- /dev/null +++ b/image/testdata/podman_save.txtar @@ -0,0 +1,33 @@ +-- index.json -- +{ + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:869d3637f2f9b10c265e4bab4b0eccfe8770520e6e903e7dd8acf33b4987bfc1", + "size": 1040, + "annotations": { + "org.opencontainers.image.ref.name": "registry.redhat.io/ubi8/nodejs-10@sha256:4bf163fdb2499b9fc965c26d58c5b7c94381cf3b0f2f018d056a8894097507e3" + } + } + ] +} +-- blobs/sha256/869d3637f2f9b10c265e4bab4b0eccfe8770520e6e903e7dd8acf33b4987bfc1 -- +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:107d07b9bdecdfbe5fbda13d7c8d63be3e3b13ce53bcd3ec54029c41df582fb1", + "size": 4091 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:3c6d585e6a72780f0632d16bb8bfd98dfc35b403a11f5cd61925ec31643a76d3", + "size": 75995687 + } + ] +} +-- blobs/sha256/3c6d585e6a72780f0632d16bb8bfd98dfc35b403a11f5cd61925ec31643a76d3.b64 -- +H4sIAAAAAAAAA+3RQQrCMBCF4Vl7ipwgzbSd5DzFhRuhUCNoT2+yKLpRQQgi/t/mQWYgA8930lwokllNTRYecyNqqjpYCn3Z0xCTibP2p4mcT3lanJP9Mq/r9fneu/mP8t10PMw+X3K7P2rBcRxf9G9b/zHF8q79UMKFdifd/Xn/tf3dt48AAAAAAAAAAAAAAAAA8JEbVuueDAAoAAA=