// Package site assembles fiesta.rastrillo.org: the committed releases // under their exact versions, a rewrite per major onto its newest // release, the response headers that let browsers keep what never // changes, and the pages at the root. // // Build is a pure function of the two trees it is handed, so the same // releases and pages are the same site on anyone's machine. cmd/fiesta // writes what Build returns, and a static host serves it as is. package site import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "io/fs" "regexp" "slices" "sort" "strconv" "strings" ) // sumsName is the file every release carries, one ` ` line // per file, which internal/build writes and Release checks. const sumsName = "SHA256SUMS" // redirectsName is the rules file at the root of a static site, in the // format Netlify introduced and CARLOS's edge serves. It is the one // file Build writes that is neither a release nor a page. const redirectsName = "_redirects" // StampName is the file cmd/fiesta leaves at the top of a directory it // owns, fiesta release's release directories included, so a later run // knows the directory is its own to empty. It is committed with the // release and is the one hidden file Release expects: it is skipped, // never published, and never listed in SHA256SUMS. cmd/fiesta's claim // uses this constant, so the two cannot disagree. const StampName = ".fiesta" // hidden reports whether any segment of a slash path starts with a // dot: an editor's swap file, a Finder's .DS_Store, a stamp. func hidden(p string) bool { for _, seg := range strings.Split(p, "/") { if strings.HasPrefix(seg, ".") { return true } } return false } // strictVersion is X.Y.Z and nothing else: no v, no prerelease, no // build metadata, no leading zero. A release directory is named this // way or it is not a release. var strictVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) // allDigits is a first path segment that reserves a major's namespace: // /1/, /2/ and so on, whether or not that major has a release yet. var allDigits = regexp.MustCompile(`^[0-9]+$`) // version is a parsed X.Y.Z, for ordering. type version struct { major, minor, patch int name string } // parseVersion reads a strict X.Y.Z, or reports that name is not one. func parseVersion(name string) (version, bool) { m := strictVersion.FindStringSubmatch(name) if m == nil { return version{}, false } v := version{name: name} // A component that does not fit an int is not a version either: Atoi // saturates on overflow, so two such versions would read as one // number, and the rewrite could land on the older of them. var err error if v.major, err = strconv.Atoi(m[1]); err != nil { return version{}, false } if v.minor, err = strconv.Atoi(m[2]); err != nil { return version{}, false } if v.patch, err = strconv.Atoi(m[3]); err != nil { return version{}, false } return v, true } // The edge's limits on one _redirects file, from CARLOS's // internal/edge/redirects.go: 2,000 rules, 256 KiB and 2,048 bytes a // line (which one rule per major cannot reach once every component // fits an int, but a page's rule could). A site past any of them // assembles here and is refused by the packer, so it is refused here // first, naming the figure. const ( redirectsMaxRules = 2000 redirectsMaxBytes = 256 << 10 redirectsMaxLine = 2048 ) // Rules are what the site's rules files say beyond the release // rewrites: what the pages ask for, as internal/www works it out from // the pages it assembled, and which releases are published. type Rules struct { // Redirects are _redirects lines, written one a line as given, // after the release rewrites, so a page's rule never decides a /M/ // URL first. Redirects []string // Immutable are pages, by their address on the site, whose name // carries a hash of their bytes, so the bytes at that address can // never change. _headers lets browsers keep each for a year. Immutable []string // Published are releases, by version, whose directories are their // tags', so their bytes can never change: _headers lets browsers // keep each for a year. Every other release gets /M/'s five // minutes, because until it is tagged it may be rebuilt under the // same name, and a browser told it never changes would keep the // old bytes. Empty is the safe default for every build but the one // that ships. Build checks only that each is a release it holds; // that it matches its tag is the caller's to check // (TagDifferences), since Build reads no repository. Published []string } // less orders versions numerically, so 1.10.0 is newer than 1.9.3. func (v version) less(w version) bool { if v.major != w.major { return v.major < w.major } if v.minor != w.minor { return v.minor < w.minor } return v.patch < w.patch } // Build assembles the site from releases, a tree of X.Y.Z directories // as fiesta release writes them, and pages, a tree of files for the // root. It returns every file keyed by its path on the site: // // - X.Y.Z/ for every file of every release, each checked // against the release's SHA256SUMS first (Release); // - _redirects, one rule per major, newest major first, sending // /M/* to that major's newest release: `/M/* /M.y.z/:splat 200`; // then rules.Redirects, the pages' rules, one a line as given, so // a page's rule never decides a /M/ URL first; // - _headers, a five-minute cache on each major's /M/* and on each // release's /M.y.z/* that is not in rules.Published, a year's // immutable one on each that is, all open to other origins, then // a year's on each of rules.Immutable (headersFor); // - every page, at the path it has under pages. // // Anything else is an error, and the first error is the whole answer: // a release directory not named strictly X.Y.Z, a loose file at the // top of releases, a release whose files disagree with its sums, or a // page that would shadow a version. CARLOS's edge serves a real file // at the request path before any rewrite that is not forced, so a page // under an all-digit first segment would answer for /M/*, released or // not, and a page under a version-shaped segment would answer for that // release. Both are refused, and so is a page named _redirects or // _headers, which are the rules files. So is a redirect that is not // one line of its file, an immutable address that is not a page's, a // published version that is not a release here or is named twice, and // rules that take either file past the edge's limits. func Build(releases fs.FS, pages fs.FS, rules Rules) (map[string][]byte, error) { out := map[string][]byte{} entries, err := fs.ReadDir(releases, ".") if err != nil { return nil, fmt.Errorf("reading releases: %w", err) } var versions []version for _, e := range entries { v, ok := parseVersion(e.Name()) if !ok || !e.IsDir() { return nil, fmt.Errorf("releases/%s is not a release: a release is a directory named X.Y.Z, nothing else", e.Name()) } dir, err := fs.Sub(releases, v.name) if err != nil { return nil, err } files, err := Release(dir) if err != nil { return nil, fmt.Errorf("releases/%s/%w", v.name, err) } for name, data := range files { out[v.name+"/"+name] = data } versions = append(versions, v) } // The newest release of each major, majors newest first. newest := map[int]version{} for _, v := range versions { if cur, ok := newest[v.major]; !ok || cur.less(v) { newest[v.major] = v } } majors := make([]int, 0, len(newest)) for m := range newest { majors = append(majors, m) } sort.Sort(sort.Reverse(sort.IntSlice(majors))) if len(majors) > redirectsMaxRules { return nil, fmt.Errorf("%s would hold %d rules, one per major, and the edge accepts %d", redirectsName, len(majors), redirectsMaxRules) } if n := len(majors) + len(rules.Redirects); n > redirectsMaxRules { return nil, fmt.Errorf("%s would hold %d rules, %d for the releases and %d for the pages, and the edge accepts %d", redirectsName, n, len(majors), len(rules.Redirects), redirectsMaxRules) } var redirects bytes.Buffer for _, m := range majors { fmt.Fprintf(&redirects, "/%d/* /%s/:splat 200\n", m, newest[m].name) } for _, r := range rules.Redirects { switch { case strings.ContainsAny(r, "\n\r"): return nil, fmt.Errorf("the %s rule %q is not one line", redirectsName, r) case strings.TrimSpace(r) == "": return nil, fmt.Errorf("a %s rule is empty", redirectsName) case len(r) > redirectsMaxLine: return nil, fmt.Errorf("the %s rule %.40q... is %d bytes, and the edge accepts %d a line", redirectsName, r, len(r), redirectsMaxLine) } redirects.WriteString(r + "\n") } if redirects.Len() > redirectsMaxBytes { return nil, fmt.Errorf("%s would be %d bytes, and the edge accepts %d", redirectsName, redirects.Len(), redirectsMaxBytes) } out[redirectsName] = redirects.Bytes() err = fs.WalkDir(pages, ".", func(p string, d fs.DirEntry, err error) error { if err != nil { return fmt.Errorf("reading pages: %w", err) } if p == "." { return nil } // A hidden page is refused rather than skipped: pages/ is // committed, so a dotfile there is a mistake someone should // see, and skipping it quietly would also hide a stamp that // a wrong -pages pointed at. Directories too, so an empty // .git or .cache under pages/ is named, not passed over. if hidden(p) { return fmt.Errorf("page %s is hidden, and a hidden file is never published; remove it from pages", p) } if d.IsDir() { return nil } // Checked per file rather than per directory, so the refusal // names a page someone wrote and not the directory above it. first, _, _ := strings.Cut(p, "/") switch { case allDigits.MatchString(first): return fmt.Errorf("page %s would shadow /%s/, the rewrite onto the newest %s.x release, so no page may start with digits alone", p, first, first) case strictVersion.MatchString(first): return fmt.Errorf("page %s would shadow the release directory %s/, so no page may start with a version", p, first) case first == redirectsName || first == headersName: return fmt.Errorf("page %s is a rules file, which the site writes itself", p) } if !d.Type().IsRegular() { return fmt.Errorf("page %s is not a regular file", p) } data, err := fs.ReadFile(pages, p) if err != nil { return fmt.Errorf("reading page %s: %w", p, err) } out[p] = data return nil }) if err != nil { return nil, err } published := map[string]bool{} for _, p := range rules.Published { switch { case published[p]: return nil, fmt.Errorf("the published version %q is named twice", p) case !slices.ContainsFunc(versions, func(v version) bool { return v.name == p }): return nil, fmt.Errorf("the published version %q is not a release under releases/", p) } published[p] = true } headers, err := headersFor(versions, majors, published, rules.Immutable, out) if err != nil { return nil, err } out[headersName] = headers return out, nil } // headersFor is the site's _headers: a short cache on each major's // /M/ and on each release's /M.y.z/ that is not published, a year on // each that is, all open to other origins, and a year on each // immutable page, in that order. Every file it does not name keeps the // edge's no-cache, the pages included, so a deploy shows on the next // load. // // Each immutable address must be a page of the site, not a release's // file, written so the edge reads it back as that page's path: rooted, // and free of what a path line reads as a pattern (":name", "*", a // percent escape), as a query or fragment, or as the line's end // (white space). Otherwise the rule would name nothing, or something // else, and the page would quietly keep no-cache. func headersFor(versions []version, majors []int, published map[string]bool, immutable []string, out map[string][]byte) ([]byte, error) { ordered := slices.Clone(versions) slices.SortFunc(ordered, func(a, b version) int { switch { case a.less(b): return -1 case b.less(a): return 1 } return 0 }) ascending := slices.Clone(majors) slices.Sort(ascending) // Majors, then releases, then pages: none of their paths overlap // today, but where one ever does, the later and more specific rule // is the one that wins. The two kinds of release never share a // path, so their order between themselves decides nothing. majorRules := headerGroup{ kind: "majors", why: []string{ "/M/ moves to each new M.x release, so browsers keep it five minutes.", "The cross-origin headers let pages on other sites fetch these files,", "load them as modules and pin them with integrity hashes.", }, headers: append([]header{{"Cache-Control", cacheBriefly}}, crossOrigin...), } for _, m := range ascending { majorRules.paths = append(majorRules.paths, fmt.Sprintf("/%d/*", m)) } candidateRules := headerGroup{ kind: "unpublished releases", why: []string{"An untagged release may be rebuilt under its name, so browsers keep it five minutes."}, headers: append([]header{{"Cache-Control", cacheBriefly}}, crossOrigin...), } releaseRules := headerGroup{ kind: "published releases", why: []string{"A published release never changes, so browsers keep it for a year."}, headers: append([]header{{"Cache-Control", cacheForever}}, crossOrigin...), } for _, v := range ordered { if published[v.name] { releaseRules.paths = append(releaseRules.paths, "/"+v.name+"/*") } else { candidateRules.paths = append(candidateRules.paths, "/"+v.name+"/*") } } pageRules := headerGroup{ kind: "immutable pages", why: []string{"Each of these names carries a hash of its bytes, so it never changes."}, headers: []header{{"Cache-Control", cacheForever}}, } for _, p := range immutable { name, rooted := strings.CutPrefix(p, "/") first, _, _ := strings.Cut(name, "/") switch { case !rooted: return nil, fmt.Errorf("the immutable address %q is not rooted; write it as the site serves it, from /", p) case strings.ContainsFunc(p, func(r rune) bool { return r <= ' ' || strings.ContainsRune(":*%?#", r) }): return nil, fmt.Errorf("the immutable address %q holds a character a %s path line reads as more than a path", p, headersName) } if _, ok := out[name]; !ok || strictVersion.MatchString(first) { return nil, fmt.Errorf("the immutable address %q is not a page of the site", p) } pageRules.paths = append(pageRules.paths, p) } slices.Sort(pageRules.paths) return headersFile([]headerGroup{majorRules, candidateRules, releaseRules, pageRules}) } // Release reads one release directory and checks it against its // SHA256SUMS: every file is listed and its bytes match, and every // listed file is present. It returns the files keyed by path, the sums // file included, or the first disagreement. Every error begins with // the path of the file at fault, relative to dir, so a caller that // knows where dir is can prefix it. A release that fails this is not // published, and fiesta release will not replace it as a candidate, // because it is not what Fiesta wrote. // // Only regular files are a release's. A link, or anything else, is // refused by name, the sums file included: fiesta release writes none, // and reading through one would check bytes that live somewhere else. // // The one file not held to the sums is StampName at the top of the // release, fiesta release's own ownership mark, which is skipped and // never returned. Any other hidden file, and a stamp anywhere deeper, // is refused by name whether or not SHA256SUMS lists it: fiesta // release writes none, so a listed one was put there by hand, and a // hidden file is never published. func Release(dir fs.FS) (map[string][]byte, error) { if fi, err := fs.Lstat(dir, sumsName); err != nil { return nil, fmt.Errorf("%s: %w", sumsName, err) } else if !fi.Mode().IsRegular() { return nil, fmt.Errorf("%s is not a regular file, so it is not a release file", sumsName) } sums, err := fs.ReadFile(dir, sumsName) if err != nil { return nil, fmt.Errorf("%s: %w", sumsName, err) } listed := map[string]string{} for i, line := range strings.Split(strings.TrimSuffix(string(sums), "\n"), "\n") { hash, name, ok := strings.Cut(line, " ") if !ok || len(hash) != sha256.Size*2 || name == "" { return nil, fmt.Errorf("%s line %d is not ` `: %q", sumsName, i+1, line) } listed[name] = hash } files := map[string][]byte{sumsName: sums} err = fs.WalkDir(dir, ".", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if p == "." || d.IsDir() { return nil } if p == StampName { return nil } if hidden(p) { return fmt.Errorf("%s is hidden, and a hidden file is never published, so it is not a release file", p) } if !d.Type().IsRegular() { return fmt.Errorf("%s is not a regular file, so it is not a release file", p) } if p == sumsName { return nil } want, ok := listed[p] if !ok { return fmt.Errorf("%s is not listed in %s", p, sumsName) } data, err := fs.ReadFile(dir, p) if err != nil { return err } sum := sha256.Sum256(data) if hex.EncodeToString(sum[:]) != want { return fmt.Errorf("%s does not match its line in %s", p, sumsName) } files[p] = data return nil }) if err != nil { return nil, err } for name := range listed { if _, ok := files[name]; !ok { return nil, fmt.Errorf("%s is listed in %s and missing", name, sumsName) } } return files, nil } // headersName is the file of response headers by path at the root of // a static site, in the format Netlify and Cloudflare Pages use and // CARLOS's edge serves from v0.31.1. Build writes it beside // redirectsName. const headersName = "_headers" // The edge's limits on one _headers file, from CARLOS's // internal/edge/headers.go: 100 path lines and 2,048 characters a // line. Its third limit, 256 KiB, cannot be reached under the other // two: 90 paths of 2,048 characters, with the few fixed header lines // under each, come to under 200 KiB. // // Build stops at headersStopPaths, ten short of the edge, so the file // is reshaped while there is room, not on the day a release must ship. const ( headersMaxPaths = 100 headersStopPaths = 90 headersMaxLine = 2048 ) // The Cache-Control values Build writes. immutable stops a browser // revalidating even on a reload, so it goes only where the bytes at // the address can never change: a published release, and a name that // carries its bytes' hash. A candidate release gets cacheBriefly, as // /M/ does. const ( cacheForever = "public, max-age=31536000, immutable" cacheBriefly = "public, max-age=300" headersIntro = "# Response headers for CARLOS's edge, written by fiesta site.\n" + "# A rule matches the address the browser asked for, before _redirects\n" + "# rewrites it. Where two rules set a header, the later one wins.\n" + "# A file no rule names keeps the edge's Cache-Control: no-cache.\n" ) // crossOrigin opens a release's files to pages on other sites. The // first lets them fetch the strings, load a module script and pin a // file by its integrity hash; the second lets a page that isolates // itself (Cross-Origin-Embedder-Policy) load the files at all. The // gallery's own assets, which only the gallery's pages load, go // without. var crossOrigin = []header{ {"Access-Control-Allow-Origin", "*"}, {"Cross-Origin-Resource-Policy", "cross-origin"}, } // header is one header line of a _headers rule. type header struct{ name, value string } // headerGroup is a run of _headers rules that set the same headers, // under a comment that says why. type headerGroup struct { kind string // what the paths are, for a refusal's count why []string // the comment's lines paths []string headers []header } // headerChecks are the headers CARLOS's edge lets a site set, by the // name Build writes, each with the values the edge accepts. carlos // ship refuses a file with any other, so Build refuses it first: the // headers are constants here, and one edited into something the edge // refuses fails the build, not the ship. Cache-Control is held a // little tighter than the edge holds it: no quoted values, which // Build never writes. var headerChecks = map[string]func(string) bool{ "Cache-Control": func(v string) bool { return tokenList(v, true) }, "Access-Control-Allow-Origin": func(v string) bool { return v == "*" }, "Access-Control-Expose-Headers": func(v string) bool { return tokenList(v, false) }, "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 == "*" }, } // tokenList reports whether v is a comma-separated list of HTTP tokens // (RFC 9110), each optionally token=token when assign is set. func tokenList(v string, assign bool) bool { for _, item := range strings.Split(v, ",") { item = strings.Trim(item, " ") if name, val, ok := strings.Cut(item, "="); ok && assign { if !isToken(name) || !isToken(val) { return false } } else if !isToken(item) { return false } } return true } // isToken reports whether s is one or more of the characters an HTTP // header or directive name may hold. 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 } // headersFile writes groups as a _headers file, in order, or refuses // what the edge would refuse: more than headersStopPaths paths, a line // over headersMaxLine, or a header outside headerChecks. func headersFile(groups []headerGroup) ([]byte, error) { var counts []string n := 0 for _, g := range groups { if len(g.paths) > 0 { n += len(g.paths) counts = append(counts, fmt.Sprintf("%d %s", len(g.paths), g.kind)) } } if n > headersStopPaths { return nil, fmt.Errorf("%s would name %d paths (%s), and the build stops at %d, short of the edge's limit of %d; "+ "give the gallery's hashed assets a directory of their own so one rule covers them, or ask CARLOS for a higher limit", headersName, n, strings.Join(counts, ", "), headersStopPaths, headersMaxPaths) } var b bytes.Buffer b.WriteString(headersIntro) for _, g := range groups { if len(g.paths) == 0 { continue } b.WriteString("\n") for _, line := range g.why { b.WriteString("# " + line + "\n") } for _, p := range g.paths { if len(p) > headersMaxLine { return nil, fmt.Errorf("the %s path %.40q... is %d characters, and the edge accepts %d a line", headersName, p, len(p), headersMaxLine) } b.WriteString(p + "\n") for _, h := range g.headers { if check := headerChecks[h.name]; check == nil || !check(h.value) { return nil, fmt.Errorf("%s would set %s: %q on %s, and the edge refuses it; "+ "set only the headers in headerChecks, with the values it allows", headersName, h.name, h.value, p) } b.WriteString(" " + h.name + ": " + h.value + "\n") } } } return b.Bytes(), nil }