rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package site assembles fiesta.rastrillo.org: the committed releases
2// under their exact versions, a rewrite per major onto its newest
3// release, the response headers that let browsers keep what never
4// changes, and the pages at the root.
5//
6// Build is a pure function of the two trees it is handed, so the same
7// releases and pages are the same site on anyone's machine. cmd/fiesta
8// writes what Build returns, and a static host serves it as is.
9package site
10
11import (
12 "bytes"
13 "crypto/sha256"
14 "encoding/hex"
15 "fmt"
16 "io/fs"
17 "regexp"
18 "slices"
19 "sort"
20 "strconv"
21 "strings"
22)
23
24// sumsName is the file every release carries, one `<hex> <path>` line
25// per file, which internal/build writes and Release checks.
26const sumsName = "SHA256SUMS"
27
28// redirectsName is the rules file at the root of a static site, in the
29// format Netlify introduced and CARLOS's edge serves. It is the one
30// file Build writes that is neither a release nor a page.
31const redirectsName = "_redirects"
32
33// StampName is the file cmd/fiesta leaves at the top of a directory it
34// owns, fiesta release's release directories included, so a later run
35// knows the directory is its own to empty. It is committed with the
36// release and is the one hidden file Release expects: it is skipped,
37// never published, and never listed in SHA256SUMS. cmd/fiesta's claim
38// uses this constant, so the two cannot disagree.
39const StampName = ".fiesta"
40
41// hidden reports whether any segment of a slash path starts with a
42// dot: an editor's swap file, a Finder's .DS_Store, a stamp.
43func hidden(p string) bool {
44 for _, seg := range strings.Split(p, "/") {
45 if strings.HasPrefix(seg, ".") {
46 return true
47 }
48 }
49 return false
50}
51
52// strictVersion is X.Y.Z and nothing else: no v, no prerelease, no
53// build metadata, no leading zero. A release directory is named this
54// way or it is not a release.
55var strictVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
56
57// allDigits is a first path segment that reserves a major's namespace:
58// /1/, /2/ and so on, whether or not that major has a release yet.
59var allDigits = regexp.MustCompile(`^[0-9]+$`)
60
61// version is a parsed X.Y.Z, for ordering.
62type version struct {
63 major, minor, patch int
64 name string
65}
66
67// parseVersion reads a strict X.Y.Z, or reports that name is not one.
68func parseVersion(name string) (version, bool) {
69 m := strictVersion.FindStringSubmatch(name)
70 if m == nil {
71 return version{}, false
72 }
73 v := version{name: name}
74 // A component that does not fit an int is not a version either: Atoi
75 // saturates on overflow, so two such versions would read as one
76 // number, and the rewrite could land on the older of them.
77 var err error
78 if v.major, err = strconv.Atoi(m[1]); err != nil {
79 return version{}, false
80 }
81 if v.minor, err = strconv.Atoi(m[2]); err != nil {
82 return version{}, false
83 }
84 if v.patch, err = strconv.Atoi(m[3]); err != nil {
85 return version{}, false
86 }
87 return v, true
88}
89
90// The edge's limits on one _redirects file, from CARLOS's
91// internal/edge/redirects.go: 2,000 rules, 256 KiB and 2,048 bytes a
92// line (which one rule per major cannot reach once every component
93// fits an int, but a page's rule could). A site past any of them
94// assembles here and is refused by the packer, so it is refused here
95// first, naming the figure.
96const (
97 redirectsMaxRules = 2000
98 redirectsMaxBytes = 256 << 10
99 redirectsMaxLine = 2048
100)
101
102// Rules are what the site's rules files say beyond the release
103// rewrites: what the pages ask for, as internal/www works it out from
104// the pages it assembled, and which releases are published.
105type Rules struct {
106 // Redirects are _redirects lines, written one a line as given,
107 // after the release rewrites, so a page's rule never decides a /M/
108 // URL first.
109 Redirects []string
110 // Immutable are pages, by their address on the site, whose name
111 // carries a hash of their bytes, so the bytes at that address can
112 // never change. _headers lets browsers keep each for a year.
113 Immutable []string
114 // Published are releases, by version, whose directories are their
115 // tags', so their bytes can never change: _headers lets browsers
116 // keep each for a year. Every other release gets /M/'s five
117 // minutes, because until it is tagged it may be rebuilt under the
118 // same name, and a browser told it never changes would keep the
119 // old bytes. Empty is the safe default for every build but the one
120 // that ships. Build checks only that each is a release it holds;
121 // that it matches its tag is the caller's to check
122 // (TagDifferences), since Build reads no repository.
123 Published []string
124}
125
126// less orders versions numerically, so 1.10.0 is newer than 1.9.3.
127func (v version) less(w version) bool {
128 if v.major != w.major {
129 return v.major < w.major
130 }
131 if v.minor != w.minor {
132 return v.minor < w.minor
133 }
134 return v.patch < w.patch
135}
136
137// Build assembles the site from releases, a tree of X.Y.Z directories
138// as fiesta release writes them, and pages, a tree of files for the
139// root. It returns every file keyed by its path on the site:
140//
141// - X.Y.Z/<file> for every file of every release, each checked
142// against the release's SHA256SUMS first (Release);
143// - _redirects, one rule per major, newest major first, sending
144// /M/* to that major's newest release: `/M/* /M.y.z/:splat 200`;
145// then rules.Redirects, the pages' rules, one a line as given, so
146// a page's rule never decides a /M/ URL first;
147// - _headers, a five-minute cache on each major's /M/* and on each
148// release's /M.y.z/* that is not in rules.Published, a year's
149// immutable one on each that is, all open to other origins, then
150// a year's on each of rules.Immutable (headersFor);
151// - every page, at the path it has under pages.
152//
153// Anything else is an error, and the first error is the whole answer:
154// a release directory not named strictly X.Y.Z, a loose file at the
155// top of releases, a release whose files disagree with its sums, or a
156// page that would shadow a version. CARLOS's edge serves a real file
157// at the request path before any rewrite that is not forced, so a page
158// under an all-digit first segment would answer for /M/*, released or
159// not, and a page under a version-shaped segment would answer for that
160// release. Both are refused, and so is a page named _redirects or
161// _headers, which are the rules files. So is a redirect that is not
162// one line of its file, an immutable address that is not a page's, a
163// published version that is not a release here or is named twice, and
164// rules that take either file past the edge's limits.
165func Build(releases fs.FS, pages fs.FS, rules Rules) (map[string][]byte, error) {
166 out := map[string][]byte{}
167 entries, err := fs.ReadDir(releases, ".")
168 if err != nil {
169 return nil, fmt.Errorf("reading releases: %w", err)
170 }
171 var versions []version
172 for _, e := range entries {
173 v, ok := parseVersion(e.Name())
174 if !ok || !e.IsDir() {
175 return nil, fmt.Errorf("releases/%s is not a release: a release is a directory named X.Y.Z, nothing else", e.Name())
176 }
177 dir, err := fs.Sub(releases, v.name)
178 if err != nil {
179 return nil, err
180 }
181 files, err := Release(dir)
182 if err != nil {
183 return nil, fmt.Errorf("releases/%s/%w", v.name, err)
184 }
185 for name, data := range files {
186 out[v.name+"/"+name] = data
187 }
188 versions = append(versions, v)
189 }
190
191 // The newest release of each major, majors newest first.
192 newest := map[int]version{}
193 for _, v := range versions {
194 if cur, ok := newest[v.major]; !ok || cur.less(v) {
195 newest[v.major] = v
196 }
197 }
198 majors := make([]int, 0, len(newest))
199 for m := range newest {
200 majors = append(majors, m)
201 }
202 sort.Sort(sort.Reverse(sort.IntSlice(majors)))
203 if len(majors) > redirectsMaxRules {
204 return nil, fmt.Errorf("%s would hold %d rules, one per major, and the edge accepts %d", redirectsName, len(majors), redirectsMaxRules)
205 }
206 if n := len(majors) + len(rules.Redirects); n > redirectsMaxRules {
207 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)
208 }
209 var redirects bytes.Buffer
210 for _, m := range majors {
211 fmt.Fprintf(&redirects, "/%d/* /%s/:splat 200\n", m, newest[m].name)
212 }
213 for _, r := range rules.Redirects {
214 switch {
215 case strings.ContainsAny(r, "\n\r"):
216 return nil, fmt.Errorf("the %s rule %q is not one line", redirectsName, r)
217 case strings.TrimSpace(r) == "":
218 return nil, fmt.Errorf("a %s rule is empty", redirectsName)
219 case len(r) > redirectsMaxLine:
220 return nil, fmt.Errorf("the %s rule %.40q... is %d bytes, and the edge accepts %d a line", redirectsName, r, len(r), redirectsMaxLine)
221 }
222 redirects.WriteString(r + "\n")
223 }
224 if redirects.Len() > redirectsMaxBytes {
225 return nil, fmt.Errorf("%s would be %d bytes, and the edge accepts %d", redirectsName, redirects.Len(), redirectsMaxBytes)
226 }
227 out[redirectsName] = redirects.Bytes()
228
229 err = fs.WalkDir(pages, ".", func(p string, d fs.DirEntry, err error) error {
230 if err != nil {
231 return fmt.Errorf("reading pages: %w", err)
232 }
233 if p == "." {
234 return nil
235 }
236 // A hidden page is refused rather than skipped: pages/ is
237 // committed, so a dotfile there is a mistake someone should
238 // see, and skipping it quietly would also hide a stamp that
239 // a wrong -pages pointed at. Directories too, so an empty
240 // .git or .cache under pages/ is named, not passed over.
241 if hidden(p) {
242 return fmt.Errorf("page %s is hidden, and a hidden file is never published; remove it from pages", p)
243 }
244 if d.IsDir() {
245 return nil
246 }
247 // Checked per file rather than per directory, so the refusal
248 // names a page someone wrote and not the directory above it.
249 first, _, _ := strings.Cut(p, "/")
250 switch {
251 case allDigits.MatchString(first):
252 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)
253 case strictVersion.MatchString(first):
254 return fmt.Errorf("page %s would shadow the release directory %s/, so no page may start with a version", p, first)
255 case first == redirectsName || first == headersName:
256 return fmt.Errorf("page %s is a rules file, which the site writes itself", p)
257 }
258 if !d.Type().IsRegular() {
259 return fmt.Errorf("page %s is not a regular file", p)
260 }
261 data, err := fs.ReadFile(pages, p)
262 if err != nil {
263 return fmt.Errorf("reading page %s: %w", p, err)
264 }
265 out[p] = data
266 return nil
267 })
268 if err != nil {
269 return nil, err
270 }
271
272 published := map[string]bool{}
273 for _, p := range rules.Published {
274 switch {
275 case published[p]:
276 return nil, fmt.Errorf("the published version %q is named twice", p)
277 case !slices.ContainsFunc(versions, func(v version) bool { return v.name == p }):
278 return nil, fmt.Errorf("the published version %q is not a release under releases/", p)
279 }
280 published[p] = true
281 }
282 headers, err := headersFor(versions, majors, published, rules.Immutable, out)
283 if err != nil {
284 return nil, err
285 }
286 out[headersName] = headers
287 return out, nil
288}
289
290// headersFor is the site's _headers: a short cache on each major's
291// /M/ and on each release's /M.y.z/ that is not published, a year on
292// each that is, all open to other origins, and a year on each
293// immutable page, in that order. Every file it does not name keeps the
294// edge's no-cache, the pages included, so a deploy shows on the next
295// load.
296//
297// Each immutable address must be a page of the site, not a release's
298// file, written so the edge reads it back as that page's path: rooted,
299// and free of what a path line reads as a pattern (":name", "*", a
300// percent escape), as a query or fragment, or as the line's end
301// (white space). Otherwise the rule would name nothing, or something
302// else, and the page would quietly keep no-cache.
303func headersFor(versions []version, majors []int, published map[string]bool, immutable []string, out map[string][]byte) ([]byte, error) {
304 ordered := slices.Clone(versions)
305 slices.SortFunc(ordered, func(a, b version) int {
306 switch {
307 case a.less(b):
308 return -1
309 case b.less(a):
310 return 1
311 }
312 return 0
313 })
314 ascending := slices.Clone(majors)
315 slices.Sort(ascending)
316
317 // Majors, then releases, then pages: none of their paths overlap
318 // today, but where one ever does, the later and more specific rule
319 // is the one that wins. The two kinds of release never share a
320 // path, so their order between themselves decides nothing.
321 majorRules := headerGroup{
322 kind: "majors",
323 why: []string{
324 "/M/ moves to each new M.x release, so browsers keep it five minutes.",
325 "The cross-origin headers let pages on other sites fetch these files,",
326 "load them as modules and pin them with integrity hashes.",
327 },
328 headers: append([]header{{"Cache-Control", cacheBriefly}}, crossOrigin...),
329 }
330 for _, m := range ascending {
331 majorRules.paths = append(majorRules.paths, fmt.Sprintf("/%d/*", m))
332 }
333 candidateRules := headerGroup{
334 kind: "unpublished releases",
335 why: []string{"An untagged release may be rebuilt under its name, so browsers keep it five minutes."},
336 headers: append([]header{{"Cache-Control", cacheBriefly}}, crossOrigin...),
337 }
338 releaseRules := headerGroup{
339 kind: "published releases",
340 why: []string{"A published release never changes, so browsers keep it for a year."},
341 headers: append([]header{{"Cache-Control", cacheForever}}, crossOrigin...),
342 }
343 for _, v := range ordered {
344 if published[v.name] {
345 releaseRules.paths = append(releaseRules.paths, "/"+v.name+"/*")
346 } else {
347 candidateRules.paths = append(candidateRules.paths, "/"+v.name+"/*")
348 }
349 }
350 pageRules := headerGroup{
351 kind: "immutable pages",
352 why: []string{"Each of these names carries a hash of its bytes, so it never changes."},
353 headers: []header{{"Cache-Control", cacheForever}},
354 }
355 for _, p := range immutable {
356 name, rooted := strings.CutPrefix(p, "/")
357 first, _, _ := strings.Cut(name, "/")
358 switch {
359 case !rooted:
360 return nil, fmt.Errorf("the immutable address %q is not rooted; write it as the site serves it, from /", p)
361 case strings.ContainsFunc(p, func(r rune) bool { return r <= ' ' || strings.ContainsRune(":*%?#", r) }):
362 return nil, fmt.Errorf("the immutable address %q holds a character a %s path line reads as more than a path", p, headersName)
363 }
364 if _, ok := out[name]; !ok || strictVersion.MatchString(first) {
365 return nil, fmt.Errorf("the immutable address %q is not a page of the site", p)
366 }
367 pageRules.paths = append(pageRules.paths, p)
368 }
369 slices.Sort(pageRules.paths)
370 return headersFile([]headerGroup{majorRules, candidateRules, releaseRules, pageRules})
371}
372
373// Release reads one release directory and checks it against its
374// SHA256SUMS: every file is listed and its bytes match, and every
375// listed file is present. It returns the files keyed by path, the sums
376// file included, or the first disagreement. Every error begins with
377// the path of the file at fault, relative to dir, so a caller that
378// knows where dir is can prefix it. A release that fails this is not
379// published, and fiesta release will not replace it as a candidate,
380// because it is not what Fiesta wrote.
381//
382// Only regular files are a release's. A link, or anything else, is
383// refused by name, the sums file included: fiesta release writes none,
384// and reading through one would check bytes that live somewhere else.
385//
386// The one file not held to the sums is StampName at the top of the
387// release, fiesta release's own ownership mark, which is skipped and
388// never returned. Any other hidden file, and a stamp anywhere deeper,
389// is refused by name whether or not SHA256SUMS lists it: fiesta
390// release writes none, so a listed one was put there by hand, and a
391// hidden file is never published.
392func Release(dir fs.FS) (map[string][]byte, error) {
393 if fi, err := fs.Lstat(dir, sumsName); err != nil {
394 return nil, fmt.Errorf("%s: %w", sumsName, err)
395 } else if !fi.Mode().IsRegular() {
396 return nil, fmt.Errorf("%s is not a regular file, so it is not a release file", sumsName)
397 }
398 sums, err := fs.ReadFile(dir, sumsName)
399 if err != nil {
400 return nil, fmt.Errorf("%s: %w", sumsName, err)
401 }
402 listed := map[string]string{}
403 for i, line := range strings.Split(strings.TrimSuffix(string(sums), "\n"), "\n") {
404 hash, name, ok := strings.Cut(line, " ")
405 if !ok || len(hash) != sha256.Size*2 || name == "" {
406 return nil, fmt.Errorf("%s line %d is not `<sha256> <path>`: %q", sumsName, i+1, line)
407 }
408 listed[name] = hash
409 }
410 files := map[string][]byte{sumsName: sums}
411 err = fs.WalkDir(dir, ".", func(p string, d fs.DirEntry, err error) error {
412 if err != nil {
413 return err
414 }
415 if p == "." || d.IsDir() {
416 return nil
417 }
418 if p == StampName {
419 return nil
420 }
421 if hidden(p) {
422 return fmt.Errorf("%s is hidden, and a hidden file is never published, so it is not a release file", p)
423 }
424 if !d.Type().IsRegular() {
425 return fmt.Errorf("%s is not a regular file, so it is not a release file", p)
426 }
427 if p == sumsName {
428 return nil
429 }
430 want, ok := listed[p]
431 if !ok {
432 return fmt.Errorf("%s is not listed in %s", p, sumsName)
433 }
434 data, err := fs.ReadFile(dir, p)
435 if err != nil {
436 return err
437 }
438 sum := sha256.Sum256(data)
439 if hex.EncodeToString(sum[:]) != want {
440 return fmt.Errorf("%s does not match its line in %s", p, sumsName)
441 }
442 files[p] = data
443 return nil
444 })
445 if err != nil {
446 return nil, err
447 }
448 for name := range listed {
449 if _, ok := files[name]; !ok {
450 return nil, fmt.Errorf("%s is listed in %s and missing", name, sumsName)
451 }
452 }
453 return files, nil
454}
455
456// headersName is the file of response headers by path at the root of
457// a static site, in the format Netlify and Cloudflare Pages use and
458// CARLOS's edge serves from v0.31.1. Build writes it beside
459// redirectsName.
460const headersName = "_headers"
461
462// The edge's limits on one _headers file, from CARLOS's
463// internal/edge/headers.go: 100 path lines and 2,048 characters a
464// line. Its third limit, 256 KiB, cannot be reached under the other
465// two: 90 paths of 2,048 characters, with the few fixed header lines
466// under each, come to under 200 KiB.
467//
468// Build stops at headersStopPaths, ten short of the edge, so the file
469// is reshaped while there is room, not on the day a release must ship.
470const (
471 headersMaxPaths = 100
472 headersStopPaths = 90
473 headersMaxLine = 2048
474)
475
476// The Cache-Control values Build writes. immutable stops a browser
477// revalidating even on a reload, so it goes only where the bytes at
478// the address can never change: a published release, and a name that
479// carries its bytes' hash. A candidate release gets cacheBriefly, as
480// /M/ does.
481const (
482 cacheForever = "public, max-age=31536000, immutable"
483 cacheBriefly = "public, max-age=300"
484 headersIntro = "# Response headers for CARLOS's edge, written by fiesta site.\n" +
485 "# A rule matches the address the browser asked for, before _redirects\n" +
486 "# rewrites it. Where two rules set a header, the later one wins.\n" +
487 "# A file no rule names keeps the edge's Cache-Control: no-cache.\n"
488)
489
490// crossOrigin opens a release's files to pages on other sites. The
491// first lets them fetch the strings, load a module script and pin a
492// file by its integrity hash; the second lets a page that isolates
493// itself (Cross-Origin-Embedder-Policy) load the files at all. The
494// gallery's own assets, which only the gallery's pages load, go
495// without.
496var crossOrigin = []header{
497 {"Access-Control-Allow-Origin", "*"},
498 {"Cross-Origin-Resource-Policy", "cross-origin"},
499}
500
501// header is one header line of a _headers rule.
502type header struct{ name, value string }
503
504// headerGroup is a run of _headers rules that set the same headers,
505// under a comment that says why.
506type headerGroup struct {
507 kind string // what the paths are, for a refusal's count
508 why []string // the comment's lines
509 paths []string
510 headers []header
511}
512
513// headerChecks are the headers CARLOS's edge lets a site set, by the
514// name Build writes, each with the values the edge accepts. carlos
515// ship refuses a file with any other, so Build refuses it first: the
516// headers are constants here, and one edited into something the edge
517// refuses fails the build, not the ship. Cache-Control is held a
518// little tighter than the edge holds it: no quoted values, which
519// Build never writes.
520var headerChecks = map[string]func(string) bool{
521 "Cache-Control": func(v string) bool { return tokenList(v, true) },
522 "Access-Control-Allow-Origin": func(v string) bool { return v == "*" },
523 "Access-Control-Expose-Headers": func(v string) bool { return tokenList(v, false) },
524 "Cross-Origin-Resource-Policy": func(v string) bool {
525 return v == "same-origin" || v == "same-site" || v == "cross-origin"
526 },
527 "Timing-Allow-Origin": func(v string) bool { return v == "*" },
528}
529
530// tokenList reports whether v is a comma-separated list of HTTP tokens
531// (RFC 9110), each optionally token=token when assign is set.
532func tokenList(v string, assign bool) bool {
533 for _, item := range strings.Split(v, ",") {
534 item = strings.Trim(item, " ")
535 if name, val, ok := strings.Cut(item, "="); ok && assign {
536 if !isToken(name) || !isToken(val) {
537 return false
538 }
539 } else if !isToken(item) {
540 return false
541 }
542 }
543 return true
544}
545
546// isToken reports whether s is one or more of the characters an HTTP
547// header or directive name may hold.
548func isToken(s string) bool {
549 if s == "" {
550 return false
551 }
552 for i := 0; i < len(s); i++ {
553 c := s[i]
554 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0) {
555 return false
556 }
557 }
558 return true
559}
560
561// headersFile writes groups as a _headers file, in order, or refuses
562// what the edge would refuse: more than headersStopPaths paths, a line
563// over headersMaxLine, or a header outside headerChecks.
564func headersFile(groups []headerGroup) ([]byte, error) {
565 var counts []string
566 n := 0
567 for _, g := range groups {
568 if len(g.paths) > 0 {
569 n += len(g.paths)
570 counts = append(counts, fmt.Sprintf("%d %s", len(g.paths), g.kind))
571 }
572 }
573 if n > headersStopPaths {
574 return nil, fmt.Errorf("%s would name %d paths (%s), and the build stops at %d, short of the edge's limit of %d; "+
575 "give the gallery's hashed assets a directory of their own so one rule covers them, or ask CARLOS for a higher limit",
576 headersName, n, strings.Join(counts, ", "), headersStopPaths, headersMaxPaths)
577 }
578 var b bytes.Buffer
579 b.WriteString(headersIntro)
580 for _, g := range groups {
581 if len(g.paths) == 0 {
582 continue
583 }
584 b.WriteString("\n")
585 for _, line := range g.why {
586 b.WriteString("# " + line + "\n")
587 }
588 for _, p := range g.paths {
589 if len(p) > headersMaxLine {
590 return nil, fmt.Errorf("the %s path %.40q... is %d characters, and the edge accepts %d a line", headersName, p, len(p), headersMaxLine)
591 }
592 b.WriteString(p + "\n")
593 for _, h := range g.headers {
594 if check := headerChecks[h.name]; check == nil || !check(h.value) {
595 return nil, fmt.Errorf("%s would set %s: %q on %s, and the edge refuses it; "+
596 "set only the headers in headerChecks, with the values it allows", headersName, h.name, h.value, p)
597 }
598 b.WriteString(" " + h.name + ": " + h.value + "\n")
599 }
600 }
601 }
602 return b.Bytes(), nil
603}
604