rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package www_test
2
3// The _redirects grammar and matching of CARLOS's static edge, mirrored
4// from internal/edge/redirects.go in carlos/platform (origin/main,
5// a8b52609) so the test server answers as the edge does and the tests
6// can refuse a file `carlos ship` would refuse. Only the edge's own
7// policy around the rules (its probe guard) is left out. When the edge
8// changes its grammar, this changes with it.
9
10import (
11 "errors"
12 "fmt"
13 "net"
14 "net/url"
15 "path"
16 "strconv"
17 "strings"
18)
19
20// The edge's limits on one _redirects file.
21const (
22 redirectsMaxBytes = 256 << 10
23 redirectsMaxRules = 2000
24 redirectsMaxLine = 2048
25)
26
27// rule is one parsed line of a _redirects file.
28type rule struct {
29 line int // 1-based line number
30 host string // lowercased; "" matches any host
31 segs []string // source path segments; ":name" captures one
32 splat bool // the source ends in "*"
33 prefix string // a URL destination's "scheme://host", never substituted
34 to string // the destination template after prefix
35 status int
36 force bool // "!": applies even when a file exists at the request path
37}
38
39// isRedirect reports whether status is one a redirect rule may answer with.
40func isRedirect(status int) bool {
41 switch status {
42 case 301, 302, 303, 307, 308:
43 return true
44 }
45 return false
46}
47
48// parseRedirects parses a _redirects file as `carlos ship` does: every
49// line it cannot use is an error, named by number.
50func parseRedirects(data []byte) ([]rule, error) {
51 if len(data) > redirectsMaxBytes {
52 return nil, fmt.Errorf("_redirects is %d bytes; the limit is %d", len(data), redirectsMaxBytes)
53 }
54 var rules []rule
55 var errs []error
56 text := strings.TrimPrefix(string(data), "\ufeff")
57 for i, line := range strings.Split(text, "\n") {
58 n := i + 1
59 line = strings.TrimSpace(strings.TrimSuffix(line, "\r"))
60 if line == "" || strings.HasPrefix(line, "#") {
61 continue
62 }
63 if len(line) > redirectsMaxLine {
64 errs = append(errs, fmt.Errorf("line %d: longer than %d characters", n, redirectsMaxLine))
65 continue
66 }
67 r, err := parseRedirectLine(line)
68 if err != nil {
69 errs = append(errs, fmt.Errorf("line %d: %v", n, err))
70 continue
71 }
72 if len(rules) == redirectsMaxRules {
73 errs = append(errs, fmt.Errorf("line %d: more than %d rules", n, redirectsMaxRules))
74 continue
75 }
76 r.line = n
77 rules = append(rules, r)
78 }
79 return rules, errors.Join(errs...)
80}
81
82func parseRedirectLine(line string) (rule, error) {
83 var fields []string
84 for _, f := range strings.Fields(line) {
85 if strings.HasPrefix(f, "#") {
86 break
87 }
88 fields = append(fields, f)
89 }
90 var r rule
91 switch {
92 case len(fields) < 2:
93 return r, errors.New("want a source and a destination")
94 case len(fields) > 3:
95 if strings.Contains(fields[3], "=") {
96 return r, fmt.Errorf("conditions such as %q are not supported", fields[3])
97 }
98 return r, fmt.Errorf("unexpected %q after the status", fields[3])
99 }
100
101 r.status = 301
102 if len(fields) == 3 {
103 st := fields[2]
104 if s, ok := strings.CutSuffix(st, "!"); ok {
105 r.force, st = true, s
106 }
107 code, err := strconv.Atoi(st)
108 if err != nil || !(isRedirect(code) || code == 200 || code == 404) {
109 return r, fmt.Errorf("status %q: want 301, 302, 303, 307, 308, 200 or 404, optionally followed by !", fields[2])
110 }
111 r.status = code
112 }
113
114 src := fields[0]
115 if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
116 u, err := url.Parse(src)
117 if err != nil || u.Host == "" || u.User != nil || u.Port() != "" || u.RawQuery != "" || u.Fragment != "" {
118 return r, fmt.Errorf("source %q: want a URL with only a host and a path", src)
119 }
120 r.host = hostName(u.Hostname())
121 src = u.Path
122 if src == "" {
123 src = "/"
124 }
125 }
126 if !strings.HasPrefix(src, "/") {
127 return r, fmt.Errorf("source %q: want a path starting with / or an http(s) URL", fields[0])
128 }
129 if strings.ContainsAny(src, "?#") {
130 return r, fmt.Errorf("source %q: query strings and fragments are not matched", fields[0])
131 }
132 names := map[string]bool{}
133 trimmed := strings.Trim(src, "/")
134 for _, seg := range strings.Split(trimmed, "/") {
135 switch {
136 case seg == "":
137 if trimmed != "" {
138 return r, fmt.Errorf("source %q: empty path segment", fields[0])
139 }
140 case r.splat:
141 return r, fmt.Errorf("source %q: * must be the last segment", fields[0])
142 case seg == "*":
143 r.splat = true
144 case strings.Contains(seg, "*"):
145 return r, fmt.Errorf("source %q: * must be a whole segment", fields[0])
146 case strings.HasPrefix(seg, ":"):
147 name := seg[1:]
148 if !validPlaceholder(name) || name == "splat" {
149 return r, fmt.Errorf("source %q: bad placeholder %q", fields[0], seg)
150 }
151 if names[name] {
152 return r, fmt.Errorf("source %q: placeholder %q appears twice", fields[0], seg)
153 }
154 names[name] = true
155 r.segs = append(r.segs, seg)
156 default:
157 dec, err := url.PathUnescape(seg)
158 if err != nil || strings.Contains(dec, "/") {
159 return r, fmt.Errorf("source %q: bad percent-encoding in %q", fields[0], seg)
160 }
161 if strings.HasPrefix(dec, ":") {
162 return r, fmt.Errorf("source %q: %q would read as a placeholder", fields[0], seg)
163 }
164 if dec == "." || dec == ".." {
165 return r, fmt.Errorf("source %q: dot segments never match", fields[0])
166 }
167 r.segs = append(r.segs, dec)
168 }
169 }
170
171 dst := fields[1]
172 switch {
173 case strings.HasPrefix(dst, "http://") || strings.HasPrefix(dst, "https://"):
174 if !isRedirect(r.status) {
175 return r, fmt.Errorf("destination %q: a %d serves a file from this site, so it takes a path, not a URL", dst, r.status)
176 }
177 u, err := url.Parse(dst)
178 if err != nil || u.Host == "" || u.User != nil {
179 return r, fmt.Errorf("destination %q: not a usable URL", dst)
180 }
181 i := len(u.Scheme) + len("://") + len(u.Host)
182 if !strings.EqualFold(dst[:i], u.Scheme+"://"+u.Host) {
183 return r, fmt.Errorf("destination %q: not a usable URL", dst)
184 }
185 r.prefix, r.to = dst[:i], dst[i:]
186 if r.to != "" && !strings.ContainsAny(r.to[:1], "/?#") {
187 return r, fmt.Errorf("destination %q: not a usable URL", dst)
188 }
189 case strings.HasPrefix(dst, "//") || strings.HasPrefix(dst, "/\\"):
190 return r, fmt.Errorf("destination %q: name another host with https://", dst)
191 case strings.HasPrefix(dst, "/"):
192 r.to = dst
193 default:
194 return r, fmt.Errorf("destination %q: want a path starting with / or an http(s) URL", dst)
195 }
196 return r, nil
197}
198
199// validPlaceholder: a placeholder name is letters, digits and underscores.
200func validPlaceholder(name string) bool {
201 if name == "" {
202 return false
203 }
204 for _, c := range name {
205 if !(c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') {
206 return false
207 }
208 }
209 return true
210}
211
212// hostName is a request's host as the edge compares it: lowercased,
213// with no port and no trailing dot. The edge also folds an
214// internationalised name to ASCII, which no host here needs.
215func hostName(h string) string {
216 if host, _, err := net.SplitHostPort(h); err == nil {
217 h = host
218 }
219 return strings.TrimSuffix(strings.ToLower(h), ".")
220}
221
222// segments splits a cleaned, rooted path into its segments; "/" has none.
223func segments(p string) []string {
224 if p == "/" {
225 return nil
226 }
227 return strings.Split(p[1:], "/")
228}
229
230// match reports whether the rule applies to a request for host whose
231// cleaned path splits into reqSegs, and returns what the source captured.
232func (r *rule) match(host string, reqSegs []string) (map[string]string, bool) {
233 if r.host != "" && r.host != host {
234 return nil, false
235 }
236 if len(reqSegs) < len(r.segs) || !r.splat && len(reqSegs) != len(r.segs) {
237 return nil, false
238 }
239 caps := map[string]string{}
240 for i, seg := range r.segs {
241 if strings.HasPrefix(seg, ":") {
242 caps[seg[1:]] = reqSegs[i]
243 } else if seg != reqSegs[i] {
244 return nil, false
245 }
246 }
247 if r.splat {
248 caps["splat"] = strings.Join(reqSegs[len(r.segs):], "/")
249 }
250 return caps, true
251}
252
253// expand substitutes the captures into the destination template, each
254// percent-encoded for a Location when escape is set, raw for a
255// rewrite's file lookup. A ":name" the source did not capture stays.
256func expand(t string, caps map[string]string, escape bool) string {
257 var b strings.Builder
258 inQuery := false
259 for i := 0; i < len(t); {
260 c := t[i]
261 if c == '?' || c == '#' {
262 inQuery = true
263 }
264 if c != ':' {
265 b.WriteByte(c)
266 i++
267 continue
268 }
269 j := i + 1
270 for j < len(t) && validPlaceholder(t[j:j+1]) {
271 j++
272 }
273 v, ok := caps[t[i+1:j]]
274 if !ok {
275 b.WriteString(t[i:j])
276 i = j
277 continue
278 }
279 switch {
280 case !escape:
281 b.WriteString(v)
282 case inQuery:
283 b.WriteString(url.QueryEscape(v))
284 default:
285 parts := strings.Split(v, "/")
286 for k, p := range parts {
287 parts[k] = url.PathEscape(p)
288 }
289 b.WriteString(strings.Join(parts, "/"))
290 }
291 i = j
292 }
293 return b.String()
294}
295
296// action is what a matched rule asks for: a redirect's status and
297// Location, or a rewrite's status and file.
298type action struct {
299 status int
300 location string
301 file string
302}
303
304// resolve finds the first rule that applies to host and the cleaned
305// path p. exists is whether the tree has a file at p, which shadows
306// every rule not forced. rawPath and rawQuery are the request's own, so
307// a redirect to the URL asked for is skipped rather than looped.
308func resolve(rules []rule, host, p, rawPath, rawQuery string, exists bool) (action, bool) {
309 reqSegs := segments(p)
310 for i := range rules {
311 r := &rules[i]
312 if exists && !r.force {
313 continue
314 }
315 caps, ok := r.match(host, reqSegs)
316 if !ok {
317 continue
318 }
319 if sp := caps["splat"]; sp != "" && strings.HasSuffix(rawPath, "/") {
320 caps["splat"] = sp + "/"
321 }
322 if !isRedirect(r.status) {
323 to := r.to
324 if k := strings.IndexAny(to, "?#"); k >= 0 {
325 to = to[:k]
326 }
327 return action{status: r.status, file: path.Clean("/" + expand(to, caps, false))}, true
328 }
329 loc := expand(r.to, caps, true)
330 frag := ""
331 if k := strings.IndexByte(loc, '#'); k >= 0 {
332 loc, frag = loc[:k], loc[k:]
333 }
334 if r.prefix == "" {
335 pth, q, hasQ := strings.Cut(loc, "?")
336 trailing := strings.HasSuffix(pth, "/")
337 pth = path.Clean("/" + pth)
338 if trailing && pth != "/" {
339 pth += "/"
340 }
341 loc = pth
342 if hasQ {
343 loc += "?" + q
344 }
345 }
346 if !strings.Contains(loc, "?") && rawQuery != "" {
347 loc += "?" + rawQuery
348 }
349 if r.prefix == "" || hostOf(r.prefix) == host {
350 pth, q, _ := strings.Cut(loc, "?")
351 if pth == "" {
352 pth = "/"
353 }
354 if u, err := url.Parse(pth); err == nil && u.Path == rawPath && q == rawQuery {
355 continue
356 }
357 }
358 return action{status: r.status, location: r.prefix + loc + frag}, true
359 }
360 return action{}, false
361}
362
363// names reports whether some rule names the cleaned path p outright:
364// no splat and no ":name", its segments p's.
365func names(rules []rule, host, p string) bool {
366 reqSegs := segments(p)
367outer:
368 for i := range rules {
369 r := &rules[i]
370 if r.splat || len(r.segs) != len(reqSegs) || r.host != "" && r.host != host {
371 continue
372 }
373 for j, seg := range r.segs {
374 if strings.HasPrefix(seg, ":") || seg != reqSegs[j] {
375 continue outer
376 }
377 }
378 return true
379 }
380 return false
381}
382
383// redirectsSlashForm reports whether the rules would redirect the slash
384// form of rawPath, given that the directory there exists.
385func redirectsSlashForm(rules []rule, host, p, rawPath, rawQuery string) bool {
386 act, ok := resolve(rules, host, p, rawPath+"/", rawQuery, true)
387 return ok && act.location != ""
388}
389
390// hostOf is the host of a "scheme://host[:port]" prefix.
391func hostOf(prefix string) string {
392 _, h, _ := strings.Cut(prefix, "://")
393 return hostName(h)
394}
395