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 }