1// Package route parses Docker Registry V2 request paths into one validated
2// operation shared by authorization and dispatch. Authorization and the
3// handler must never derive the repository independently: a mismatch lets a
4// request be authorized for one repository and served from another.
5package route
6
7import (
8 "errors"
9 "fmt"
10 "strings"
11
12 "github.com/bnema/gordon/pkg/validation"
13)
14
15// Parsing errors. ErrNotFound means the path is not a registry route;
16// ErrInvalid means the route shape is recognized but a component is
17// malformed (bad repository, reference, digest, or upload id).
18var (
19 ErrNotFound = errors.New("registry route not found")
20 ErrInvalid = errors.New("registry route invalid")
21)
22
23// Registry error codes for malformed components, matching the Docker
24// Registry V2 error format the handler returns.
25const (
26 CodeNameInvalid = "NAME_INVALID"
27 CodeTagInvalid = "TAG_INVALID"
28 CodeDigestInvalid = "DIGEST_INVALID"
29 CodeBlobUploadInvalid = "BLOB_UPLOAD_INVALID"
30)
31
32// ParseError is a malformed-component error carrying the registry error
33// code the handler must return. It wraps ErrInvalid for errors.Is.
34type ParseError struct {
35 Code string
36 Err error
37}
38
39// Error implements error.
40func (e *ParseError) Error() string { return e.Err.Error() }
41
42// Unwrap exposes the wrapped sentinel.
43func (e *ParseError) Unwrap() error { return ErrInvalid }
44
45// invalid builds a ParseError for a malformed component.
46func invalid(code, format string, args ...any) error {
47 return &ParseError{Code: code, Err: fmt.Errorf(format, args...)}
48}
49
50// Kind classifies a registry operation.
51type Kind string
52
53// Registry operation kinds.
54const (
55 KindBase Kind = "base"
56 KindCatalog Kind = "catalog"
57 KindManifest Kind = "manifest"
58 KindBlob Kind = "blob"
59 KindUpload Kind = "upload"
60 KindTagList Kind = "tags"
61)
62
63const (
64 manifestsSegment = "/manifests/"
65 blobsSegment = "/blobs/"
66 uploadsSegment = "/blobs/uploads/"
67 tagsSegment = "/tags/list"
68)
69
70// Operation is one parsed registry request. Repository is the exact
71// repository the request addresses (empty for protocol roots), so callers
72// authorize and dispatch on the same value.
73type Operation struct {
74 Kind Kind
75 Repository string
76 // Reference is the manifest tag or digest (manifest kind).
77 Reference string
78 // Digest is the blob digest (blob kind).
79 Digest string
80 // UploadID is the upload session id; empty marks a new upload (upload kind).
81 UploadID string
82}
83
84// RequiresRepositoryAuth reports whether the operation must be authorized
85// against a repository scope. Only protocol roots are exempt; every route
86// that names a repository is scoped.
87func (o Operation) RequiresRepositoryAuth() bool {
88 return o.Kind != KindBase && o.Kind != KindCatalog
89}
90
91// Parse classifies a registry request path. It resolves the repository
92// from the LAST route marker, matching the served route exactly, and
93// validates every component with the shared validation grammar.
94func Parse(path string) (Operation, error) {
95 if path == "/v2" || path == "/v2/" {
96 return Operation{Kind: KindBase}, nil
97 }
98 if !strings.HasPrefix(path, "/v2/") {
99 return Operation{}, ErrNotFound
100 }
101 rest := strings.TrimPrefix(path, "/v2/")
102 switch rest {
103 case "":
104 return Operation{Kind: KindBase}, nil
105 case "_catalog":
106 return Operation{Kind: KindCatalog}, nil
107 }
108
109 parsers := map[Kind]func(string) (Operation, bool, error){
110 KindTagList: parseTags,
111 KindUpload: parseUpload,
112 KindManifest: parseManifest,
113 KindBlob: parseBlob,
114 }
115 markers := []struct {
116 segment string
117 kind Kind
118 }{
119 {tagsSegment, KindTagList},
120 {uploadsSegment, KindUpload},
121 {manifestsSegment, KindManifest},
122 {blobsSegment, KindBlob},
123 }
124 // The served route is the marker that appears LAST in the path, so a
125 // reserved-looking segment earlier in the path can never shorten or
126 // redirect the repository.
127 best := -1
128 bestKind := Kind("")
129 for _, marker := range markers {
130 if idx := strings.LastIndex(rest, marker.segment); idx > best {
131 best = idx
132 bestKind = marker.kind
133 }
134 }
135 if parse, ok := parsers[bestKind]; ok {
136 if op, matched, err := parse(rest); matched {
137 return op, err
138 }
139 }
140 return Operation{}, ErrNotFound
141}
142
143// parseTags matches /{repo}/tags/list.
144func parseTags(rest string) (Operation, bool, error) {
145 repo, ok := strings.CutSuffix(rest, tagsSegment)
146 if !ok {
147 return Operation{}, false, nil
148 }
149 op, err := repositoryOperation(KindTagList, repo)
150 return op, true, err
151}
152
153// parseUpload matches /{repo}/blobs/uploads/{uuid?}.
154func parseUpload(rest string) (Operation, bool, error) {
155 idx := strings.LastIndex(rest, uploadsSegment)
156 if idx < 0 {
157 return Operation{}, false, nil
158 }
159 op, err := repositoryOperation(KindUpload, rest[:idx])
160 if err != nil {
161 return Operation{}, true, err
162 }
163 uploadID := rest[idx+len(uploadsSegment):]
164 if uploadID == "" {
165 return op, true, nil
166 }
167 if err := validation.ValidateUUID(uploadID); err != nil {
168 return Operation{}, true, invalid(CodeBlobUploadInvalid, "%s", err)
169 }
170 op.UploadID = uploadID
171 return op, true, nil
172}
173
174// parseManifest matches /{repo}/manifests/{reference}.
175func parseManifest(rest string) (Operation, bool, error) {
176 idx := strings.LastIndex(rest, manifestsSegment)
177 if idx < 0 {
178 return Operation{}, false, nil
179 }
180 reference := rest[idx+len(manifestsSegment):]
181 if reference == "" || strings.Contains(reference, "/") {
182 return Operation{}, true, ErrNotFound
183 }
184 if err := validation.ValidateReference(reference); err != nil {
185 return Operation{}, true, invalid(CodeTagInvalid, "%s", err)
186 }
187 op, err := repositoryOperation(KindManifest, rest[:idx])
188 if err != nil {
189 return Operation{}, true, err
190 }
191 op.Reference = reference
192 return op, true, nil
193}
194
195// parseBlob matches /{repo}/blobs/{digest}.
196func parseBlob(rest string) (Operation, bool, error) {
197 idx := strings.LastIndex(rest, blobsSegment)
198 if idx < 0 {
199 return Operation{}, false, nil
200 }
201 digest := rest[idx+len(blobsSegment):]
202 if digest == "" || strings.Contains(digest, "/") {
203 return Operation{}, true, ErrNotFound
204 }
205 if err := validation.ValidateDigest(digest); err != nil {
206 return Operation{}, true, invalid(CodeDigestInvalid, "%s", err)
207 }
208 op, err := repositoryOperation(KindBlob, rest[:idx])
209 if err != nil {
210 return Operation{}, true, err
211 }
212 op.Digest = digest
213 return op, true, nil
214}
215
216// repositoryOperation builds a repository-scoped operation, rejecting an
217// empty or malformed repository so a reserved-looking segment can never be
218// mistaken for a protocol root.
219func repositoryOperation(kind Kind, repo string) (Operation, error) {
220 if repo == "" || strings.HasSuffix(repo, "/") {
221 return Operation{}, ErrNotFound
222 }
223 if err := validation.ValidateRepositoryName(repo); err != nil {
224 return Operation{}, invalid(CodeNameInvalid, "%s", err)
225 }
226 return Operation{Kind: kind, Repository: repo}, nil
227}