diff --git a/types/blob/blob.go b/types/blob/blob.go index d72f182..a7b8373 100644 --- a/types/blob/blob.go +++ b/types/blob/blob.go @@ -19,7 +19,7 @@ type BlobConfig struct { header http.Header image ociv1.Image r ref.Ref - rc io.ReadCloser + rdr io.Reader resp *http.Response } @@ -41,9 +41,9 @@ func WithImage(image ociv1.Image) Opts { bc.image = image } } -func WithReadCloser(rc io.ReadCloser) Opts { +func WithReader(rc io.Reader) Opts { return func(bc *BlobConfig) { - bc.rc = rc + bc.rdr = rc } } func WithRef(r ref.Ref) Opts { diff --git a/types/blob/blob_test.go b/types/blob/blob_test.go new file mode 100644 index 0000000..b0301c7 --- /dev/null +++ b/types/blob/blob_test.go @@ -0,0 +1,286 @@ +package blob + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "testing" + + "github.com/opencontainers/go-digest" + ociv1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/regclient/regclient/types" + "github.com/regclient/regclient/types/ref" +) + +var ( + exRef, _ = ref.New("localhost:5000/library/alpine:latest") + exBlob = []byte(` + { + "created": "2021-11-24T20:19:40.483367546Z", + "architecture": "amd64", + "os": "linux", + "config": { + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ], + "Cmd": [ + "/bin/sh" + ] + }, + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759" + ] + }, + "history": [ + { + "created": "2021-11-24T20:19:40.199700946Z", + "created_by": "/bin/sh -c #(nop) ADD file:9233f6f2237d79659a9521f7e390df217cec49f1a8aa3a12147bbca1956acdb9 in / " + }, + { + "created": "2021-11-24T20:19:40.483367546Z", + "created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]", + "empty_layer": true + } + ] + } + `) + exLen = int64(len(exBlob)) + exDigest = digest.FromBytes(exBlob) + exMT = types.MediaTypeDocker2ImageConfig + exHeaders = http.Header{ + "Content-Type": {types.MediaTypeDocker2ImageConfig}, + "Content-Length": {fmt.Sprintf("%d", exLen)}, + "Docker-Content-Digest": {exDigest.String()}, + } + exResp = http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Header: exHeaders, + ContentLength: exLen, + Body: io.NopCloser(bytes.NewReader(exBlob)), + } +) + +func TestCommon(t *testing.T) { + // create test list + tests := []struct { + name string + opts []Opts + eBytes []byte + eDigest digest.Digest + eHeaders http.Header + eLen int64 + eMT string + }{ + { + name: "empty", + }, + { + name: "reader", + opts: []Opts{WithReader(io.NopCloser(bytes.NewReader(exBlob)))}, + eBytes: exBlob, + eDigest: exDigest, + eLen: exLen, + }, + { + name: "descriptor", + opts: []Opts{ + WithReader(io.NopCloser(bytes.NewReader(exBlob))), + WithDesc(ociv1.Descriptor{ + MediaType: exMT, + Digest: exDigest, + Size: exLen, + }), + WithRef(exRef), + }, + eBytes: exBlob, + eDigest: exDigest, + eLen: exLen, + eMT: exMT, + }, + { + name: "headers", + opts: []Opts{ + WithReader(io.NopCloser(bytes.NewReader(exBlob))), + WithHeader(exHeaders), + WithRef(exRef), + }, + eBytes: exBlob, + eDigest: exDigest, + eHeaders: exHeaders, + eLen: exLen, + eMT: exMT, + }, + { + name: "response", + opts: []Opts{ + WithResp(&exResp), + WithRef(exRef), + }, + eBytes: exBlob, + eDigest: exDigest, + eHeaders: exHeaders, + eLen: exLen, + eMT: exMT, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := NewReader(tt.opts...) + if len(tt.eBytes) > 0 { + bb, err := b.RawBody() + if err != nil { + t.Errorf("rawbody: %v", err) + return + } + if bytes.Compare(bb, tt.eBytes) != 0 { + t.Errorf("rawbody, expected %s, received %s", string(tt.eBytes), string(bb)) + } + } + if tt.eDigest != "" && b.Digest() != tt.eDigest { + t.Errorf("digest, expected %s, received %s", tt.eDigest, b.Digest()) + } + if tt.eLen > 0 && b.Length() != tt.eLen { + t.Errorf("length, expected %d, received %d", tt.eLen, b.Length()) + } + if tt.eMT != "" && b.MediaType() != tt.eMT { + t.Errorf("media type, expected %s, received %s", tt.eMT, b.MediaType()) + } + if tt.eHeaders != nil { + bHeader := b.RawHeaders() + for k, v := range tt.eHeaders { + if _, ok := bHeader[k]; !ok { + t.Errorf("missing header: %s", k) + } else if !cmpSliceString(v, bHeader[k]) { + t.Errorf("header mismatch for key %s, expected %v, received %v", k, v, bHeader[k]) + } + } + } + }) + } +} + +func TestReader(t *testing.T) { + t.Run("empty", func(t *testing.T) { + // create empty blob + b := NewReader() + + // test read, expect error + _, err := b.RawBody() + if err == nil { + t.Errorf("unexpected success") + return + } + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Errorf("unexpected err from rawbody: %v", err) + } + }) + + t.Run("readseek", func(t *testing.T) { + // create blob + b := NewReader( + WithReader(bytes.NewReader(exBlob)), + ) + // test read and seek on blob1 + bl := 500 + bb := make([]byte, bl) + i, err := b.Read(bb) + if err != nil { + t.Errorf("read err: %v", err) + return + } + if i != bl { + t.Errorf("read length, expected %d, received %d", bl, i) + } + bSeek, ok := b.(io.Seeker) + if !ok { + t.Errorf("seek interface missing") + return + } + _, err = bSeek.Seek(5, io.SeekStart) + if err == nil { + t.Errorf("seek to non-zero position did not fail") + } + pos, err := bSeek.Seek(0, io.SeekStart) + if err != nil { + t.Errorf("seek err: %v", err) + return + } + if pos != 0 { + t.Errorf("seek pos, expected 0, received %d", pos) + } + bb, err = io.ReadAll(b) + if err != nil { + t.Errorf("readall: %v", err) + return + } + if b.Digest() != exDigest { + t.Errorf("digest mismatch, expected %s, received %s", exDigest, b.Digest()) + } + if b.Length() != exLen { + t.Errorf("length mismatch, expected %d, received %d", exLen, b.Length()) + } + }) + + t.Run("ociconfig", func(t *testing.T) { + // create blob + b := NewReader( + WithReader(io.NopCloser(bytes.NewReader(exBlob))), + WithDesc(ociv1.Descriptor{ + MediaType: exMT, + Digest: exDigest, + Size: exLen, + }), + WithRef(exRef), + ) + // test ToOCIConfig on blob 2 + oc, err := b.ToOCIConfig() + if err != nil { + t.Errorf("ToOCIConfig: %v", err) + return + } + if exDigest != oc.Digest() { + t.Errorf("digest, expected %s, received %s", exDigest, oc.Digest()) + } + ocb, err := oc.RawBody() + if err != nil { + t.Errorf("config rawbody: %v", err) + return + } + if bytes.Compare(exBlob, ocb) != 0 { + t.Errorf("config bytes, expected %s, received %s", string(exBlob), string(ocb)) + } + }) + + t.Run("rawbytes", func(t *testing.T) { + // create blob + b := NewReader( + WithReader(io.NopCloser(bytes.NewReader(exBlob))), + ) + // test RawBytes on blob 3 + bb, err := b.RawBody() + if err != nil { + t.Errorf("rawbody: %v", err) + return + } + if bytes.Compare(exBlob, bb) != 0 { + t.Errorf("config bytes, expected %s, received %s", string(exBlob), string(bb)) + } + }) +} + +func cmpSliceString(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/types/blob/reader.go b/types/blob/reader.go index 80adde3..00220f7 100644 --- a/types/blob/reader.go +++ b/types/blob/reader.go @@ -23,7 +23,7 @@ type reader struct { common readBytes int64 reader io.Reader - origRdr io.ReadCloser + origRdr io.Reader digester digest.Digester } @@ -35,15 +35,24 @@ func NewReader(opts ...Opts) Reader { } if bc.resp != nil { // extract headers and reader if other fields not passed + if bc.header == nil { + bc.header = bc.resp.Header + } + if bc.rdr == nil { + bc.rdr = bc.resp.Body + } + } + if bc.header != nil { + // extract fields from header if descriptor not passed if bc.desc.MediaType == "" { - bc.desc.MediaType = bc.resp.Header.Get("Content-Type") + bc.desc.MediaType = bc.header.Get("Content-Type") } if bc.desc.Size == 0 { - cl, _ := strconv.Atoi(bc.resp.Header.Get("Content-Length")) + cl, _ := strconv.Atoi(bc.header.Get("Content-Length")) bc.desc.Size = int64(cl) } if bc.desc.Digest == "" { - bc.desc.Digest = digest.FromString(bc.resp.Header.Get("Docker-Content-Digest")) + bc.desc.Digest, _ = digest.Parse(bc.header.Get("Docker-Content-Digest")) } } c := common{ @@ -54,12 +63,12 @@ func NewReader(opts ...Opts) Reader { } br := reader{ common: c, - origRdr: bc.rc, + origRdr: bc.rdr, } - if bc.rc != nil { + if bc.rdr != nil { br.blobSet = true br.digester = digest.Canonical.Digester() - br.reader = io.TeeReader(bc.rc, br.digester.Hash()) + br.reader = io.TeeReader(bc.rdr, br.digester.Hash()) } return &br } @@ -68,7 +77,12 @@ func (b *reader) Close() error { if b.origRdr == nil { return nil } - return b.origRdr.Close() + // attempt to close if available in original reader + bc, ok := b.origRdr.(io.Closer) + if !ok { + return nil + } + return bc.Close() } // RawBody returns the original body from the request @@ -78,6 +92,9 @@ func (b *reader) RawBody() ([]byte, error) { // Read passes through the read operation while computing the digest and tracking the size func (b *reader) Read(p []byte) (int, error) { + if b.reader == nil { + return 0, fmt.Errorf("blob has no reader: %w", io.ErrUnexpectedEOF) + } size, err := b.reader.Read(p) b.readBytes = b.readBytes + int64(size) if err == io.EOF {