cuelabs.dev/go/oci/ociregistry@v0.0.0-20240906074133-82eb438dd565/internal/ocirequest/request.go (about)

     1  // Copyright 2023 CUE Labs AG
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package ocirequest
    16  
    17  import (
    18  	"encoding/base64"
    19  	"errors"
    20  	"fmt"
    21  	"net/url"
    22  	"strconv"
    23  	"strings"
    24  	"unicode/utf8"
    25  
    26  	"cuelabs.dev/go/oci/ociregistry"
    27  	"cuelabs.dev/go/oci/ociregistry/ociref"
    28  )
    29  
    30  // ParseError represents an error that can happen when parsing.
    31  // The Err field holds one of the possible error values below.
    32  type ParseError struct {
    33  	Err error
    34  }
    35  
    36  func (e *ParseError) Error() string {
    37  	return e.Err.Error()
    38  }
    39  
    40  func (e *ParseError) Unwrap() error {
    41  	return e.Err
    42  }
    43  
    44  var (
    45  	ErrNotFound          = errors.New("page not found")
    46  	ErrBadlyFormedDigest = errors.New("badly formed digest")
    47  	ErrMethodNotAllowed  = errors.New("method not allowed")
    48  	ErrBadRequest        = errors.New("bad request")
    49  )
    50  
    51  type Request struct {
    52  	Kind Kind
    53  
    54  	// Repo holds the repository name. Valid for all request kinds
    55  	// except ReqCatalogList and ReqPing.
    56  	Repo string
    57  
    58  	// Digest holds the digest being used in the request.
    59  	// Valid for:
    60  	//	ReqBlobMount
    61  	//	ReqBlobUploadBlob
    62  	//	ReqBlobGet
    63  	//	ReqBlobHead
    64  	//	ReqBlobDelete
    65  	//	ReqBlobCompleteUpload
    66  	//	ReqReferrersList
    67  	//
    68  	// Valid for these manifest requests when they're referring to a digest
    69  	// rather than a tag:
    70  	//	ReqManifestGet
    71  	//	ReqManifestHead
    72  	//	ReqManifestPut
    73  	//	ReqManifestDelete
    74  	Digest string
    75  
    76  	// Tag holds the tag being used in the request. Valid for
    77  	// these manifest requests when they're referring to a tag:
    78  	//	ReqManifestGet
    79  	//	ReqManifestHead
    80  	//	ReqManifestPut
    81  	//	ReqManifestDelete
    82  	Tag string
    83  
    84  	// FromRepo holds the repository name to mount from
    85  	// for ReqBlobMount.
    86  	FromRepo string
    87  
    88  	// UploadID holds the upload identifier as used for
    89  	// chunked uploads.
    90  	// Valid for:
    91  	//	ReqBlobUploadInfo
    92  	//	ReqBlobUploadChunk
    93  	UploadID string
    94  
    95  	// ListN holds the maximum count for listing.
    96  	// It's -1 to specify that all items should be returned.
    97  	//
    98  	// Valid for:
    99  	//	ReqTagsList
   100  	//	ReqCatalog
   101  	//	ReqReferrers
   102  	ListN int
   103  
   104  	// listLast holds the item to start just after
   105  	// when listing.
   106  	//
   107  	// Valid for:
   108  	//	ReqTagsList
   109  	//	ReqCatalog
   110  	//	ReqReferrers
   111  	ListLast string
   112  }
   113  
   114  type Kind int
   115  
   116  const (
   117  	// end-1	GET	/v2/	200	404/401
   118  	ReqPing = Kind(iota)
   119  
   120  	// Blob-related endpoints
   121  
   122  	// end-2	GET	/v2/<name>/blobs/<digest>	200	404
   123  	ReqBlobGet
   124  
   125  	// end-2	HEAD	/v2/<name>/blobs/<digest>	200	404
   126  	ReqBlobHead
   127  
   128  	// end-10	DELETE	/v2/<name>/blobs/<digest>	202	404/405
   129  	ReqBlobDelete
   130  
   131  	// end-4a	POST	/v2/<name>/blobs/uploads/	202	404
   132  	ReqBlobStartUpload
   133  
   134  	// end-4b	POST	/v2/<name>/blobs/uploads/?digest=<digest>	201/202	404/400
   135  	ReqBlobUploadBlob
   136  
   137  	// end-11	POST	/v2/<name>/blobs/uploads/?mount=<digest>&from=<other_name>	201	404
   138  	ReqBlobMount
   139  
   140  	// end-13	GET	/v2/<name>/blobs/uploads/<reference>	204	404
   141  	// NOTE: despite being described in the distribution spec, this
   142  	// isn't really part of the OCI spec.
   143  	ReqBlobUploadInfo
   144  
   145  	// end-5	PATCH	/v2/<name>/blobs/uploads/<reference>	202	404/416
   146  	// NOTE: despite being described in the distribution spec, this
   147  	// isn't really part of the OCI spec.
   148  	ReqBlobUploadChunk
   149  
   150  	// end-6	PUT	/v2/<name>/blobs/uploads/<reference>?digest=<digest>	201	404/400
   151  	// NOTE: despite being described in the distribution spec, this
   152  	// isn't really part of the OCI spec.
   153  	ReqBlobCompleteUpload
   154  
   155  	// Manifest-related endpoints
   156  
   157  	// end-3	GET	/v2/<name>/manifests/<tagOrDigest>	200	404
   158  	ReqManifestGet
   159  
   160  	// end-3	HEAD	/v2/<name>/manifests/<tagOrDigest>	200	404
   161  	ReqManifestHead
   162  
   163  	// end-7	PUT	/v2/<name>/manifests/<tagOrDigest>	201	404
   164  	ReqManifestPut
   165  
   166  	// end-9	DELETE	/v2/<name>/manifests/<tagOrDigest>	202	404/400/405
   167  	ReqManifestDelete
   168  
   169  	// Tag-related endpoints
   170  
   171  	// end-8a	GET	/v2/<name>/tags/list	200	404
   172  	// end-8b	GET	/v2/<name>/tags/list?n=<integer>&last=<integer>	200	404
   173  	ReqTagsList
   174  
   175  	// Referrer-related endpoints
   176  
   177  	// end-12a	GET	/v2/<name>/referrers/<digest>	200	404/400
   178  	ReqReferrersList
   179  
   180  	// Catalog endpoints (out-of-spec)
   181  	// 	GET	/v2/_catalog
   182  	ReqCatalogList
   183  )
   184  
   185  // Parse parses the given HTTP method and URL as an OCI registry request.
   186  // It understands the endpoints described in the [distribution spec].
   187  //
   188  // If it returns an error, it will be of type *ParseError.
   189  //
   190  // [distribution spec]: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#endpoints
   191  func Parse(method string, u *url.URL) (*Request, error) {
   192  	req, err := parse(method, u)
   193  	if err != nil {
   194  		return nil, &ParseError{err}
   195  	}
   196  	return req, nil
   197  }
   198  
   199  func parse(method string, u *url.URL) (*Request, error) {
   200  	path := u.Path
   201  	urlq, err := url.ParseQuery(u.RawQuery)
   202  	if err != nil {
   203  		return nil, err
   204  	}
   205  
   206  	var rreq Request
   207  	if path == "/v2" || path == "/v2/" {
   208  		rreq.Kind = ReqPing
   209  		return &rreq, nil
   210  	}
   211  	path, ok := strings.CutPrefix(path, "/v2/")
   212  	if !ok {
   213  		return nil, ociregistry.NewError("unknown URL path", ociregistry.ErrNameUnknown.Code(), nil)
   214  	}
   215  	if path == "_catalog" {
   216  		if method != "GET" {
   217  			return nil, ErrMethodNotAllowed
   218  		}
   219  		rreq.Kind = ReqCatalogList
   220  		setListQueryParams(&rreq, urlq)
   221  		return &rreq, nil
   222  	}
   223  	uploadPath, ok := strings.CutSuffix(path, "/blobs/uploads/")
   224  	if !ok {
   225  		uploadPath, ok = strings.CutSuffix(path, "/blobs/uploads")
   226  	}
   227  	if ok {
   228  		rreq.Repo = uploadPath
   229  		if !ociref.IsValidRepository(rreq.Repo) {
   230  			return nil, ociregistry.ErrNameInvalid
   231  		}
   232  		if method != "POST" {
   233  			return nil, ErrMethodNotAllowed
   234  		}
   235  		if d := urlq.Get("mount"); d != "" {
   236  			// end-11
   237  			rreq.Digest = d
   238  			if !ociref.IsValidDigest(rreq.Digest) {
   239  				return nil, ociregistry.ErrDigestInvalid
   240  			}
   241  			rreq.FromRepo = urlq.Get("from")
   242  			if rreq.FromRepo == "" {
   243  				// There's no "from" argument so fall back to
   244  				// a regular chunked upload.
   245  				rreq.Kind = ReqBlobStartUpload
   246  				// TODO does the "mount" query argument actually take effect in some way?
   247  				rreq.Digest = ""
   248  				return &rreq, nil
   249  			}
   250  			if !ociref.IsValidRepository(rreq.FromRepo) {
   251  				return nil, ociregistry.ErrNameInvalid
   252  			}
   253  			rreq.Kind = ReqBlobMount
   254  			return &rreq, nil
   255  		}
   256  		if d := urlq.Get("digest"); d != "" {
   257  			// end-4b
   258  			rreq.Digest = d
   259  			if !ociref.IsValidDigest(d) {
   260  				return nil, ErrBadlyFormedDigest
   261  			}
   262  			rreq.Kind = ReqBlobUploadBlob
   263  			return &rreq, nil
   264  		}
   265  		// end-4a
   266  		rreq.Kind = ReqBlobStartUpload
   267  		return &rreq, nil
   268  	}
   269  	path, last, ok := cutLast(path, "/")
   270  	if !ok {
   271  		return nil, ErrNotFound
   272  	}
   273  	path, lastButOne, ok := cutLast(path, "/")
   274  	if !ok {
   275  		return nil, ErrNotFound
   276  	}
   277  	switch lastButOne {
   278  	case "blobs":
   279  		rreq.Repo = path
   280  		if !ociref.IsValidDigest(last) {
   281  			return nil, ErrBadlyFormedDigest
   282  		}
   283  		if !ociref.IsValidRepository(rreq.Repo) {
   284  			return nil, ociregistry.ErrNameInvalid
   285  		}
   286  		rreq.Digest = last
   287  		switch method {
   288  		case "GET":
   289  			rreq.Kind = ReqBlobGet
   290  		case "HEAD":
   291  			rreq.Kind = ReqBlobHead
   292  		case "DELETE":
   293  			rreq.Kind = ReqBlobDelete
   294  		default:
   295  			return nil, ErrMethodNotAllowed
   296  		}
   297  		return &rreq, nil
   298  	case "uploads":
   299  		// Note: this section is all specific to ociserver and
   300  		// isn't part of the OCI registry spec.
   301  		repo, ok := strings.CutSuffix(path, "/blobs")
   302  		if !ok {
   303  			return nil, ErrNotFound
   304  		}
   305  		rreq.Repo = repo
   306  		if !ociref.IsValidRepository(rreq.Repo) {
   307  			return nil, ociregistry.ErrNameInvalid
   308  		}
   309  		uploadID64 := last
   310  		if uploadID64 == "" {
   311  			return nil, ErrNotFound
   312  		}
   313  		uploadID, err := base64.RawURLEncoding.DecodeString(uploadID64)
   314  		if err != nil {
   315  			return nil, fmt.Errorf("invalid upload ID %q (cannot decode)", uploadID64)
   316  		}
   317  		if !utf8.Valid(uploadID) {
   318  			return nil, fmt.Errorf("upload ID %q decoded to invalid utf8", uploadID64)
   319  		}
   320  		rreq.UploadID = string(uploadID)
   321  
   322  		switch method {
   323  		case "GET":
   324  			rreq.Kind = ReqBlobUploadInfo
   325  		case "PATCH":
   326  			rreq.Kind = ReqBlobUploadChunk
   327  		case "PUT":
   328  			rreq.Kind = ReqBlobCompleteUpload
   329  			rreq.Digest = urlq.Get("digest")
   330  			if !ociref.IsValidDigest(rreq.Digest) {
   331  				return nil, ErrBadlyFormedDigest
   332  			}
   333  		default:
   334  			return nil, ErrMethodNotAllowed
   335  		}
   336  		return &rreq, nil
   337  	case "manifests":
   338  		rreq.Repo = path
   339  		if !ociref.IsValidRepository(rreq.Repo) {
   340  			return nil, ociregistry.ErrNameInvalid
   341  		}
   342  		switch {
   343  		case ociref.IsValidDigest(last):
   344  			rreq.Digest = last
   345  		case ociref.IsValidTag(last):
   346  			rreq.Tag = last
   347  		default:
   348  			return nil, ErrNotFound
   349  		}
   350  		switch method {
   351  		case "GET":
   352  			rreq.Kind = ReqManifestGet
   353  		case "HEAD":
   354  			rreq.Kind = ReqManifestHead
   355  		case "PUT":
   356  			rreq.Kind = ReqManifestPut
   357  		case "DELETE":
   358  			rreq.Kind = ReqManifestDelete
   359  		default:
   360  			return nil, ErrMethodNotAllowed
   361  		}
   362  		return &rreq, nil
   363  
   364  	case "tags":
   365  		if last != "list" {
   366  			return nil, ErrNotFound
   367  		}
   368  		if err := setListQueryParams(&rreq, urlq); err != nil {
   369  			return nil, err
   370  		}
   371  		if method != "GET" {
   372  			return nil, ErrMethodNotAllowed
   373  		}
   374  		rreq.Repo = path
   375  		if !ociref.IsValidRepository(rreq.Repo) {
   376  			return nil, ociregistry.ErrNameInvalid
   377  		}
   378  		rreq.Kind = ReqTagsList
   379  		return &rreq, nil
   380  	case "referrers":
   381  		if !ociref.IsValidDigest(last) {
   382  			return nil, ErrBadlyFormedDigest
   383  		}
   384  		if method != "GET" {
   385  			return nil, ErrMethodNotAllowed
   386  		}
   387  		rreq.Repo = path
   388  		if !ociref.IsValidRepository(rreq.Repo) {
   389  			return nil, ociregistry.ErrNameInvalid
   390  		}
   391  		// TODO is there any kind of pagination for referrers?
   392  		// We'll set ListN to be future-proof.
   393  		rreq.ListN = -1
   394  		rreq.Digest = last
   395  		rreq.Kind = ReqReferrersList
   396  		return &rreq, nil
   397  	}
   398  	return nil, ErrNotFound
   399  }
   400  
   401  func setListQueryParams(rreq *Request, urlq url.Values) error {
   402  	rreq.ListN = -1
   403  	if nstr := urlq.Get("n"); nstr != "" {
   404  		n, err := strconv.Atoi(nstr)
   405  		if err != nil {
   406  			return fmt.Errorf("n is not a valid integer: %w", ErrBadRequest)
   407  		}
   408  		rreq.ListN = n
   409  	}
   410  	rreq.ListLast = urlq.Get("last")
   411  	return nil
   412  }
   413  
   414  func cutLast(s, sep string) (before, after string, found bool) {
   415  	if i := strings.LastIndex(s, sep); i >= 0 {
   416  		return s[:i], s[i+len(sep):], true
   417  	}
   418  	return "", s, false
   419  }
   420  
   421  // ParseRange extracts the start and end offsets from a Content-Range string.
   422  // The resulting start is inclusive and the end exclusive, to match Go convention,
   423  // whereas Content-Range is inclusive on both ends.
   424  func ParseRange(s string) (start, end int64, ok bool) {
   425  	p0s, p1s, ok := strings.Cut(s, "-")
   426  	if !ok {
   427  		return 0, 0, false
   428  	}
   429  	p0, err0 := strconv.ParseInt(p0s, 10, 64)
   430  	p1, err1 := strconv.ParseInt(p1s, 10, 64)
   431  	if p1 > 0 {
   432  		p1++
   433  	}
   434  	return p0, p1, err0 == nil && err1 == nil
   435  }
   436  
   437  // RangeString formats a pair of start and end offsets in the Content-Range form.
   438  // The input start is inclusive and the end exclusive, to match Go convention,
   439  // whereas Content-Range is inclusive on both ends.
   440  func RangeString(start, end int64) string {
   441  	end--
   442  	if end < 0 {
   443  		end = 0
   444  	}
   445  	return fmt.Sprintf("%d-%d", start, end)
   446  }