package www_test // The _headers grammar and matching of CARLOS's static edge, mirrored // from internal/edge/headers.go in carlos/platform (v0.31.1) so the // tests can refuse a file `carlos ship` would refuse and read the // headers a request gets. When the edge changes its grammar, this // changes with it. import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "net/http" "os" "path" "regexp" "slices" "strings" "testing" "amadan.net/rastrillo/fiesta" ) // The edge's limits on one _headers file. const ( headersMaxBytes = 256 << 10 headersMaxPaths = 100 headersMaxLine = 2048 ) // headerRule is one path line of a _headers file and the headers under // it, in the order written. type headerRule struct { line int path string rule // the path, read as a _redirects source is set http.Header } // headerChecks are the headers the edge lets a site set, each with the // check its value must pass. var headerChecks = map[string]func(string) bool{ "Cache-Control": cacheDirectives, "Access-Control-Allow-Origin": func(v string) bool { return v == "*" }, "Access-Control-Expose-Headers": headerNames, "Cross-Origin-Resource-Policy": func(v string) bool { return v == "same-origin" || v == "same-site" || v == "cross-origin" }, "Timing-Allow-Origin": func(v string) bool { return v == "*" }, } // parseHeaders parses a _headers file as `carlos ship` does: every // line it cannot use is an error, named by number. func parseHeaders(data []byte) ([]headerRule, error) { if len(data) > headersMaxBytes { return nil, fmt.Errorf("_headers is %d bytes; the limit is %d", len(data), headersMaxBytes) } var rules []headerRule var errs []error bad := func(n int, err error) { errs = append(errs, fmt.Errorf("line %d: %v", n, err)) } var cur *headerRule seenPath, paths := false, 0 finish := func() { if cur != nil && len(cur.set) == 0 { bad(cur.line, fmt.Errorf("path %q has no headers under it", cur.path)) } else if cur != nil { rules = append(rules, *cur) } cur = nil } text := strings.TrimPrefix(string(data), "\ufeff") for i, line := range strings.Split(text, "\n") { n := i + 1 line = strings.TrimSuffix(line, "\r") trimmed := strings.Trim(line, " \t") if len(line) > headersMaxLine { bad(n, fmt.Errorf("longer than %d characters", headersMaxLine)) if trimmed != "" && line[0] != ' ' && line[0] != '\t' && !strings.HasPrefix(trimmed, "#") { finish() seenPath = true paths++ } continue } if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } if line[0] != ' ' && line[0] != '\t' { finish() seenPath = true paths++ switch { case strings.ContainsAny(trimmed, " \t"): bad(n, fmt.Errorf("path line %q: want a path and nothing else", trimmed)) continue case paths > headersMaxPaths: bad(n, fmt.Errorf("more than %d path lines", headersMaxPaths)) continue } // The edge reads a path line with the parser it reads a // _redirects source with, so the source half of a rule // is that parser here. r, err := parseRedirectLine(trimmed + " /") if err != nil { bad(n, err) continue } cur = &headerRule{line: n, path: trimmed, rule: r, set: http.Header{}} continue } if !seenPath { bad(n, errors.New("header line before a path line")) continue } if cur == nil { continue } name, value, err := parseHeaderLine(trimmed) if err != nil { bad(n, err) continue } cur.set.Set(name, value) } finish() return rules, errors.Join(errs...) } // parseHeaderLine splits and checks one "Name: value" line as the edge // does, returning the canonical name and the trimmed value. The value // is held to visible ASCII and spaces before its header's check reads // it, as the edge holds it: a quoted string passes the Cache-Control // grammar whatever it holds, and a tab beside a comma is trimmed away // with the spaces, so without this a value the edge refuses would // parse here. func parseHeaderLine(line string) (name, value string, err error) { if strings.HasPrefix(line, "!") { return "", "", fmt.Errorf("%q: removing a header with ! is not supported", line) } n, v, ok := strings.Cut(line, ":") n = strings.Trim(n, " \t") if !ok || n == "" { return "", "", fmt.Errorf("%q: want a header line like \"Name: value\"", line) } name = http.CanonicalHeaderKey(n) check, ok := headerChecks[name] if !ok { return "", "", fmt.Errorf("header %q is not supported", name) } value = strings.Trim(v, " \t") if value == "" { return "", "", fmt.Errorf("%s: the value is empty", name) } for i := 0; i < len(value); i++ { if value[i] < ' ' || value[i] > '~' { return "", "", fmt.Errorf("%s: the value may hold only visible ASCII and spaces", name) } } if !check(value) { return "", "", fmt.Errorf("%s: the edge refuses %q", name, value) } return name, value, nil } // isToken reports whether s is an HTTP token (RFC 9110). func isToken(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { c := s[i] if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0) { return false } } return true } // splitOutsideQuotes splits v at the commas outside a quoted string, // as the edge does, so no-cache="X-One, X-Two" stays one directive. // An unterminated quote leaves the rest of v in one piece, which // cacheDirectives then refuses. func splitOutsideQuotes(v string) []string { var parts []string start, quoted := 0, false for i := 0; i < len(v); i++ { switch v[i] { case '"': quoted = !quoted case ',': if !quoted { parts = append(parts, v[start:i]) start = i + 1 } } } return append(parts, v[start:]) } // cacheDirectives: a comma-separated list of directives, each a token // or token=value, the value a token or a quoted string, split only at // commas outside quotes. func cacheDirectives(v string) bool { for _, d := range splitOutsideQuotes(v) { name, val, hasVal := strings.Cut(strings.Trim(d, " \t"), "=") quoted := len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' && !strings.ContainsAny(val[1:len(val)-1], `"\`) if !isToken(name) || hasVal && !isToken(val) && !quoted { return false } } return true } // headerNames: a comma-separated list of header names. func headerNames(v string) bool { for _, n := range strings.Split(v, ",") { if !isToken(strings.Trim(n, " \t")) { return false } } return true } // lookupHeaders is what the edge adds to a file served for host at the // cleaned path p, the path the browser asked for: every matching // rule's headers in file order, the last to name a header winning. func lookupHeaders(rules []headerRule, host, p string) http.Header { out := http.Header{} segs := segments(p) for i := range rules { if _, ok := rules[i].match(host, segs); !ok { continue } for name := range rules[i].set { out.Set(name, rules[i].set.Get(name)) } } return out } // The headers fiesta site sets, as the edge reads them. const ( yearLong = "public, max-age=31536000, immutable" fiveMinute = "public, max-age=300" ) // crossOrigin are the headers that let a page on another site fetch a // release's files, load them as modules and pin them by hash. var crossOrigin = http.Header{ "Access-Control-Allow-Origin": {"*"}, "Cross-Origin-Resource-Policy": {"cross-origin"}, } // withCache is h with a Cache-Control of cc added, h left alone. func withCache(h http.Header, cc string) http.Header { out := h.Clone() if out == nil { out = http.Header{} } out.Set("Cache-Control", cc) return out } // versionDir is a release directory's name, X.Y.Z, as site.Build reads it. var versionDir = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) // hashedAsset is a design-system asset's hashed name: its stem, sixteen // hex digits and its extension. var hashedAsset = regexp.MustCompile(`^design-system/assets/.*\.([0-9a-f]{16})\.[^./]+$`) // fingerprinted reports whether the file at name is one of the design // system's hashed assets: its name carries the first sixteen hex // digits of its bytes' sha256, so other bytes would be another name. // It is worked out from the built tree alone, not from the gallery's // own list, so the two are compared rather than one copied. func fingerprinted(name string, data []byte) bool { m := hashedAsset.FindStringSubmatch(name) if m == nil { return false } sum := sha256.Sum256(data) return hex.EncodeToString(sum[:8]) == m[1] } // versionLess orders X.Y.Z names numerically. func versionLess(a, b string) int { var x, y [3]int fmt.Sscanf(a, "%d.%d.%d", &x[0], &x[1], &x[2]) fmt.Sscanf(b, "%d.%d.%d", &y[0], &y[1], &y[2]) for i := range x { if x[i] != y[i] { return x[i] - y[i] } } return 0 } // TestTheSitesHeaders reads the built site's _headers as the edge // does, twice: as every build but the one that ships makes it, with no // release published, and as fiesta site -published makes it, here with // every release but the current one published, so one tree holds both // kinds. Each is held to what the site needs: // // - exactly its rules: /M/* for each major, five minutes, since /M/ // moves to each new M.x; /X.Y.Z/* for each release not published, // five minutes too, since a candidate may be rebuilt under its // name; /X.Y.Z/* for each published release, a year and immutable, // since its tag holds it; all with the cross-origin headers; and // each of the design system's hashed assets, a year and immutable, // alone. In that order, general before specific. // - every file the browser can be served immutable is a published // release's or a hashed asset, and every one of those is // immutable; every other page gets no Cache-Control from the file, // so the edge's no-cache stays. // - every rule's path matches at least one built file. // - the file is one `carlos ship` packs, under the edge's limits. func TestTheSitesHeaders(t *testing.T) { entries, err := os.ReadDir("../../releases") if err != nil { t.Fatal(err) } var older []string for _, e := range entries { if versionDir.MatchString(e.Name()) && e.Name() != fiesta.Version { older = append(older, e.Name()) } } if len(older) == 0 { t.Fatalf("releases/ holds no release but %s, so the publish build would have nothing published to check", fiesta.Version) } pages := sitePages(t) for _, build := range []struct { name string published []string }{ {"nothing published", nil}, {"all but the current release published", older}, } { t.Run(build.name, func(t *testing.T) { siteHeadersHold(t, buildSite(t, pages, build.published), build.published) }) } } // siteHeadersHold is TestTheSitesHeaders' checks on one built tree, // whose published releases are published. func siteHeadersHold(t *testing.T, tree map[string][]byte, published []string) { data, ok := tree["_headers"] if !ok { t.Fatal("the site has no _headers") } rules, err := parseHeaders(data) if err != nil { t.Fatalf("carlos ship would refuse _headers: %v", err) } // releaseCache is what a release's files must get: a year only // once it is published. releaseCache := func(v string) string { if slices.Contains(published, v) { return yearLong } return fiveMinute } var versions []string majors := map[string]bool{} var hashed []string for name, body := range tree { first, _, _ := strings.Cut(name, "/") if versionDir.MatchString(first) && !slices.Contains(versions, first) { versions = append(versions, first) major, _, _ := strings.Cut(first, ".") majors[major] = true } if fingerprinted(name, body) { hashed = append(hashed, "/"+name) } } slices.SortFunc(versions, versionLess) slices.Sort(hashed) if !slices.Contains(versions, fiesta.Version) || !slices.Contains(versions, "1.0.0") || len(hashed) == 0 { t.Fatalf("the site holds releases %v and %d hashed assets; the checks below need 1.0.0, the current release and the gallery's assets", versions, len(hashed)) } majorList := make([]string, 0, len(majors)) for m := range majors { majorList = append(majorList, m) } slices.SortFunc(majorList, func(a, b string) int { return versionLess(a+".0.0", b+".0.0") }) t.Run("exactly its rules", func(t *testing.T) { type want struct { path string set http.Header } var wants []want for _, m := range majorList { wants = append(wants, want{"/" + m + "/*", withCache(crossOrigin, fiveMinute)}) } for _, cache := range []string{fiveMinute, yearLong} { for _, v := range versions { if releaseCache(v) == cache { wants = append(wants, want{"/" + v + "/*", withCache(crossOrigin, cache)}) } } } for _, p := range hashed { wants = append(wants, want{p, withCache(nil, yearLong)}) } for i := 0; i < max(len(rules), len(wants)); i++ { switch { case i >= len(rules): t.Errorf("_headers has no rule for %s", wants[i].path) case i >= len(wants): t.Errorf("_headers line %d: %s is a rule the site does not need", rules[i].line, rules[i].path) case rules[i].path != wants[i].path || fmt.Sprint(rules[i].set) != fmt.Sprint(wants[i].set): t.Errorf("_headers line %d: %s %v; want %s %v", rules[i].line, rules[i].path, rules[i].set, wants[i].path, wants[i].set) } } t.Logf("_headers: %d majors, %d releases (%d published), %d hashed assets", len(majorList), len(versions), len(published), len(hashed)) }) t.Run("immutable is a published release or a hashed asset", func(t *testing.T) { for name, body := range tree { if name == "_redirects" || name == "_headers" { continue } first, _, _ := strings.Cut(name, "/") got := lookupHeaders(rules, "", path.Clean("/"+name)) cc := got.Get("Cache-Control") switch { case versionDir.MatchString(first): if want := releaseCache(first); cc != want { t.Errorf("/%s gets Cache-Control %q; want %q", name, cc, want) } case fingerprinted(name, body): if cc != yearLong { t.Errorf("/%s gets Cache-Control %q; it never changes, so want %q", name, cc, yearLong) } case len(got) != 0: t.Errorf("/%s gets %v; it can change under its name, so it must keep the edge's defaults", name, got) } } }) t.Run("every rule matches a built file", func(t *testing.T) { for _, r := range rules { found := false for name := range tree { if _, ok := r.match("", segments(path.Clean("/"+name))); ok && name != "_headers" && name != "_redirects" { found = true break } } // A major's files are its newest release's, through the // rewrite, not files at /M/ itself. if !found && strings.HasSuffix(r.path, "/*") { m := strings.Trim(strings.TrimSuffix(r.path, "*"), "/") for _, v := range versions { if strings.HasPrefix(v, m+".") { found = true } } } if !found { t.Errorf("_headers line %d: %s matches no file the site serves", r.line, r.path) } } }) t.Run("served as the edge serves them", func(t *testing.T) { h := newSiteHandler(t, tree) major, _, _ := strings.Cut(fiesta.Version, ".") for _, c := range []struct { path, cache, origin string }{ // Rules match before the rewrite, so /1/ is /1/*'s, not // its release's. {"/" + major + "/fiesta.css", fiveMinute, "*"}, {"/" + fiesta.Version + "/fiesta.css", releaseCache(fiesta.Version), "*"}, {"/1.0.0/i18n/es.json", releaseCache("1.0.0"), "*"}, {hashed[0], yearLong, ""}, {"/design-system/", "no-cache", ""}, {"/", "no-cache", ""}, } { res := serve(h, c.path) if res.Code != http.StatusOK { t.Errorf("%s answers %d", c.path, res.Code) continue } if cc := res.Header().Get("Cache-Control"); cc != c.cache { t.Errorf("%s gets Cache-Control %q; want %q", c.path, cc, c.cache) } if o := res.Header().Get("Access-Control-Allow-Origin"); o != c.origin { t.Errorf("%s gets Access-Control-Allow-Origin %q; want %q", c.path, o, c.origin) } } if res := serve(h, "/_headers"); res.Code != http.StatusNotFound { t.Errorf("/_headers answers %d; the edge never serves it", res.Code) } }) t.Run("within the edge's limits", func(t *testing.T) { longest := 0 for _, l := range strings.Split(string(data), "\n") { longest = max(longest, len(l)) } if len(rules) > 90 { t.Errorf("_headers names %d paths; the build stops at 90, ten short of the edge's %d", len(rules), headersMaxPaths) } t.Logf("_headers: %d paths, %d bytes, longest line %d characters", len(rules), len(data), longest) }) } // TestServeSiteSendsHeadersLikeTheEdge holds the test server's headers // to the edge's: a file served with a 200 gets no-cache, then every // _headers rule that matches the address asked for, before any // rewrite, in file order, the last to set a header winning; ":name" // matches one segment; a redirect, a rewrite's 404 and a missing file // get none of them; and the file itself is never served. A test of // the test double, not of the site. func TestServeSiteSendsHeadersLikeTheEdge(t *testing.T) { h := newSiteHandler(t, map[string][]byte{ "_redirects": []byte("/1/* /1.0.0/:splat 200\n" + "/old /index.html 301\n" + "/gone/* /index.html 404\n"), "_headers": []byte("\ufeff# general first\n" + "/*\n" + " timing-allow-origin: *\n" + "/1/*\n" + " Cache-Control: public, max-age=300\n" + "/1.0.0/*\n" + " Cache-Control: public, max-age=31536000, immutable\n" + " Access-Control-Allow-Origin: *\n" + "\n" + "/1.0.0/:file\n" + " Cache-Control: no-transform\n" + "/old\n" + " Cache-Control: public, max-age=60\n" + "/gone/*\n" + " Cache-Control: public, max-age=60\n"), "index.html": []byte("root"), "1.0.0/fiesta.css": []byte("newest"), "1.0.0/i18n/es.js": []byte("strings"), }) for _, c := range []struct { path string status int cache, origin string timing string }{ {"/", 200, "no-cache", "", "*"}, {"/1/fiesta.css", 200, "public, max-age=300", "", "*"}, {"/1.0.0/fiesta.css", 200, "no-transform", "*", "*"}, {"/1.0.0/i18n/es.js", 200, "public, max-age=31536000, immutable", "*", "*"}, {"/old", 301, "", "", ""}, {"/gone/x", 404, "", "", ""}, {"/nope", 404, "", "", ""}, {"/_headers", 404, "", "", ""}, } { res := serve(h, c.path) got := []string{res.Header().Get("Cache-Control"), res.Header().Get("Access-Control-Allow-Origin"), res.Header().Get("Timing-Allow-Origin")} if want := []string{c.cache, c.origin, c.timing}; res.Code != c.status || !slices.Equal(got, want) { t.Errorf("GET %s: %d, Cache-Control %q, Allow-Origin %q, Timing-Allow-Origin %q; want %d %q", c.path, res.Code, got[0], got[1], got[2], c.status, want) } } } // TestServeSiteRefusesAHeadersFileCarlosWouldRefuse: the test server // reads _headers with CARLOS's grammar, so a file `carlos ship` would // refuse is refused here too, and one it packs is read. func TestServeSiteRefusesAHeadersFileCarlosWouldRefuse(t *testing.T) { var many strings.Builder for i := 0; i <= headersMaxPaths; i++ { fmt.Fprintf(&many, "/p%d\n Timing-Allow-Origin: *\n", i) } for _, file := range []string{ " Cache-Control: no-cache\n", "/a b\n Cache-Control: no-cache\n", "/a\n", "/a\n Set-Cookie: a=b\n", "/a\n Strict-Transport-Security: max-age=1\n", "/a\n Access-Control-Allow-Origin: https://example.com\n", "/a\n Cross-Origin-Resource-Policy: anywhere\n", "/a\n Cache-Control: public max-age=1\n", "/a\n Cache-Control:\n", "/a\n ! Cache-Control\n", "/a/*/b\n Cache-Control: no-cache\n", "/a\n Cache-Control: " + strings.Repeat("x", headersMaxLine) + "\n", many.String(), // Visible ASCII only, checked before the directives are read: // a quoted string passes the directive grammar whatever it // holds, and a tab is trimmed away around a directive. "/a\n Cache-Control: private=\"Ünïcode\"\n", "/a\n Cache-Control: public,\tmax-age=1\n", } { if _, err := parseHeaders([]byte(file)); err == nil { t.Errorf("%.60q parsed; CARLOS refuses it", file) } } for _, file := range []string{ "# a comment alone\n", "/a\n cache-control: no-cache\n", "/a/:x/*\n\tAccess-Control-Expose-Headers: ETag, Content-Length\n", "https://fiesta.rastrillo.org/a\n Cache-Control: max-age=1, stale-if-error=\"60\"\n", // A comma inside a quoted string does not end the directive. "/a\n Cache-Control: no-cache=\"X-One, X-Two\"\n", } { if _, err := parseHeaders([]byte(file)); err != nil { t.Errorf("%q was refused (%v); CARLOS accepts it", file, err) } } }