...

Source file src/cuelabs.dev/go/oci/ociregistry/internal/ocirequest/request.go

Documentation: cuelabs.dev/go/oci/ociregistry/internal/ocirequest

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

View as plain text