package www_test // The _redirects grammar and matching of CARLOS's static edge, mirrored // from internal/edge/redirects.go in carlos/platform (origin/main, // a8b52609) so the test server answers as the edge does and the tests // can refuse a file `carlos ship` would refuse. Only the edge's own // policy around the rules (its probe guard) is left out. When the edge // changes its grammar, this changes with it. import ( "errors" "fmt" "net" "net/url" "path" "strconv" "strings" ) // The edge's limits on one _redirects file. const ( redirectsMaxBytes = 256 << 10 redirectsMaxRules = 2000 redirectsMaxLine = 2048 ) // rule is one parsed line of a _redirects file. type rule struct { line int // 1-based line number host string // lowercased; "" matches any host segs []string // source path segments; ":name" captures one splat bool // the source ends in "*" prefix string // a URL destination's "scheme://host", never substituted to string // the destination template after prefix status int force bool // "!": applies even when a file exists at the request path } // isRedirect reports whether status is one a redirect rule may answer with. func isRedirect(status int) bool { switch status { case 301, 302, 303, 307, 308: return true } return false } // parseRedirects parses a _redirects file as `carlos ship` does: every // line it cannot use is an error, named by number. func parseRedirects(data []byte) ([]rule, error) { if len(data) > redirectsMaxBytes { return nil, fmt.Errorf("_redirects is %d bytes; the limit is %d", len(data), redirectsMaxBytes) } var rules []rule var errs []error text := strings.TrimPrefix(string(data), "\ufeff") for i, line := range strings.Split(text, "\n") { n := i + 1 line = strings.TrimSpace(strings.TrimSuffix(line, "\r")) if line == "" || strings.HasPrefix(line, "#") { continue } if len(line) > redirectsMaxLine { errs = append(errs, fmt.Errorf("line %d: longer than %d characters", n, redirectsMaxLine)) continue } r, err := parseRedirectLine(line) if err != nil { errs = append(errs, fmt.Errorf("line %d: %v", n, err)) continue } if len(rules) == redirectsMaxRules { errs = append(errs, fmt.Errorf("line %d: more than %d rules", n, redirectsMaxRules)) continue } r.line = n rules = append(rules, r) } return rules, errors.Join(errs...) } func parseRedirectLine(line string) (rule, error) { var fields []string for _, f := range strings.Fields(line) { if strings.HasPrefix(f, "#") { break } fields = append(fields, f) } var r rule switch { case len(fields) < 2: return r, errors.New("want a source and a destination") case len(fields) > 3: if strings.Contains(fields[3], "=") { return r, fmt.Errorf("conditions such as %q are not supported", fields[3]) } return r, fmt.Errorf("unexpected %q after the status", fields[3]) } r.status = 301 if len(fields) == 3 { st := fields[2] if s, ok := strings.CutSuffix(st, "!"); ok { r.force, st = true, s } code, err := strconv.Atoi(st) if err != nil || !(isRedirect(code) || code == 200 || code == 404) { return r, fmt.Errorf("status %q: want 301, 302, 303, 307, 308, 200 or 404, optionally followed by !", fields[2]) } r.status = code } src := fields[0] if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") { u, err := url.Parse(src) if err != nil || u.Host == "" || u.User != nil || u.Port() != "" || u.RawQuery != "" || u.Fragment != "" { return r, fmt.Errorf("source %q: want a URL with only a host and a path", src) } r.host = hostName(u.Hostname()) src = u.Path if src == "" { src = "/" } } if !strings.HasPrefix(src, "/") { return r, fmt.Errorf("source %q: want a path starting with / or an http(s) URL", fields[0]) } if strings.ContainsAny(src, "?#") { return r, fmt.Errorf("source %q: query strings and fragments are not matched", fields[0]) } names := map[string]bool{} trimmed := strings.Trim(src, "/") for _, seg := range strings.Split(trimmed, "/") { switch { case seg == "": if trimmed != "" { return r, fmt.Errorf("source %q: empty path segment", fields[0]) } case r.splat: return r, fmt.Errorf("source %q: * must be the last segment", fields[0]) case seg == "*": r.splat = true case strings.Contains(seg, "*"): return r, fmt.Errorf("source %q: * must be a whole segment", fields[0]) case strings.HasPrefix(seg, ":"): name := seg[1:] if !validPlaceholder(name) || name == "splat" { return r, fmt.Errorf("source %q: bad placeholder %q", fields[0], seg) } if names[name] { return r, fmt.Errorf("source %q: placeholder %q appears twice", fields[0], seg) } names[name] = true r.segs = append(r.segs, seg) default: dec, err := url.PathUnescape(seg) if err != nil || strings.Contains(dec, "/") { return r, fmt.Errorf("source %q: bad percent-encoding in %q", fields[0], seg) } if strings.HasPrefix(dec, ":") { return r, fmt.Errorf("source %q: %q would read as a placeholder", fields[0], seg) } if dec == "." || dec == ".." { return r, fmt.Errorf("source %q: dot segments never match", fields[0]) } r.segs = append(r.segs, dec) } } dst := fields[1] switch { case strings.HasPrefix(dst, "http://") || strings.HasPrefix(dst, "https://"): if !isRedirect(r.status) { 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) } u, err := url.Parse(dst) if err != nil || u.Host == "" || u.User != nil { return r, fmt.Errorf("destination %q: not a usable URL", dst) } i := len(u.Scheme) + len("://") + len(u.Host) if !strings.EqualFold(dst[:i], u.Scheme+"://"+u.Host) { return r, fmt.Errorf("destination %q: not a usable URL", dst) } r.prefix, r.to = dst[:i], dst[i:] if r.to != "" && !strings.ContainsAny(r.to[:1], "/?#") { return r, fmt.Errorf("destination %q: not a usable URL", dst) } case strings.HasPrefix(dst, "//") || strings.HasPrefix(dst, "/\\"): return r, fmt.Errorf("destination %q: name another host with https://", dst) case strings.HasPrefix(dst, "/"): r.to = dst default: return r, fmt.Errorf("destination %q: want a path starting with / or an http(s) URL", dst) } return r, nil } // validPlaceholder: a placeholder name is letters, digits and underscores. func validPlaceholder(name string) bool { if name == "" { return false } for _, c := range name { if !(c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') { return false } } return true } // hostName is a request's host as the edge compares it: lowercased, // with no port and no trailing dot. The edge also folds an // internationalised name to ASCII, which no host here needs. func hostName(h string) string { if host, _, err := net.SplitHostPort(h); err == nil { h = host } return strings.TrimSuffix(strings.ToLower(h), ".") } // segments splits a cleaned, rooted path into its segments; "/" has none. func segments(p string) []string { if p == "/" { return nil } return strings.Split(p[1:], "/") } // match reports whether the rule applies to a request for host whose // cleaned path splits into reqSegs, and returns what the source captured. func (r *rule) match(host string, reqSegs []string) (map[string]string, bool) { if r.host != "" && r.host != host { return nil, false } if len(reqSegs) < len(r.segs) || !r.splat && len(reqSegs) != len(r.segs) { return nil, false } caps := map[string]string{} for i, seg := range r.segs { if strings.HasPrefix(seg, ":") { caps[seg[1:]] = reqSegs[i] } else if seg != reqSegs[i] { return nil, false } } if r.splat { caps["splat"] = strings.Join(reqSegs[len(r.segs):], "/") } return caps, true } // expand substitutes the captures into the destination template, each // percent-encoded for a Location when escape is set, raw for a // rewrite's file lookup. A ":name" the source did not capture stays. func expand(t string, caps map[string]string, escape bool) string { var b strings.Builder inQuery := false for i := 0; i < len(t); { c := t[i] if c == '?' || c == '#' { inQuery = true } if c != ':' { b.WriteByte(c) i++ continue } j := i + 1 for j < len(t) && validPlaceholder(t[j:j+1]) { j++ } v, ok := caps[t[i+1:j]] if !ok { b.WriteString(t[i:j]) i = j continue } switch { case !escape: b.WriteString(v) case inQuery: b.WriteString(url.QueryEscape(v)) default: parts := strings.Split(v, "/") for k, p := range parts { parts[k] = url.PathEscape(p) } b.WriteString(strings.Join(parts, "/")) } i = j } return b.String() } // action is what a matched rule asks for: a redirect's status and // Location, or a rewrite's status and file. type action struct { status int location string file string } // resolve finds the first rule that applies to host and the cleaned // path p. exists is whether the tree has a file at p, which shadows // every rule not forced. rawPath and rawQuery are the request's own, so // a redirect to the URL asked for is skipped rather than looped. func resolve(rules []rule, host, p, rawPath, rawQuery string, exists bool) (action, bool) { reqSegs := segments(p) for i := range rules { r := &rules[i] if exists && !r.force { continue } caps, ok := r.match(host, reqSegs) if !ok { continue } if sp := caps["splat"]; sp != "" && strings.HasSuffix(rawPath, "/") { caps["splat"] = sp + "/" } if !isRedirect(r.status) { to := r.to if k := strings.IndexAny(to, "?#"); k >= 0 { to = to[:k] } return action{status: r.status, file: path.Clean("/" + expand(to, caps, false))}, true } loc := expand(r.to, caps, true) frag := "" if k := strings.IndexByte(loc, '#'); k >= 0 { loc, frag = loc[:k], loc[k:] } if r.prefix == "" { pth, q, hasQ := strings.Cut(loc, "?") trailing := strings.HasSuffix(pth, "/") pth = path.Clean("/" + pth) if trailing && pth != "/" { pth += "/" } loc = pth if hasQ { loc += "?" + q } } if !strings.Contains(loc, "?") && rawQuery != "" { loc += "?" + rawQuery } if r.prefix == "" || hostOf(r.prefix) == host { pth, q, _ := strings.Cut(loc, "?") if pth == "" { pth = "/" } if u, err := url.Parse(pth); err == nil && u.Path == rawPath && q == rawQuery { continue } } return action{status: r.status, location: r.prefix + loc + frag}, true } return action{}, false } // names reports whether some rule names the cleaned path p outright: // no splat and no ":name", its segments p's. func names(rules []rule, host, p string) bool { reqSegs := segments(p) outer: for i := range rules { r := &rules[i] if r.splat || len(r.segs) != len(reqSegs) || r.host != "" && r.host != host { continue } for j, seg := range r.segs { if strings.HasPrefix(seg, ":") || seg != reqSegs[j] { continue outer } } return true } return false } // redirectsSlashForm reports whether the rules would redirect the slash // form of rawPath, given that the directory there exists. func redirectsSlashForm(rules []rule, host, p, rawPath, rawQuery string) bool { act, ok := resolve(rules, host, p, rawPath+"/", rawQuery, true) return ok && act.location != "" } // hostOf is the host of a "scheme://host[:port]" prefix. func hostOf(prefix string) string { _, h, _ := strings.Cut(prefix, "://") return hostName(h) }