// The static serving class (routes with Kind=="static"): channel-tracked // file trees served at the edge. A static route names an app + release // channel; the release for that channel carries ONE artifact, site.tar.gz // (a gzipped tar of the site tree, produced by PackDir). The handler // resolves the signed channel pointer (cached, re-checked every 60s), // extracts the sha256-verified tar into ///, and // serves it with http.FileServer semantics — index.html for directories, // clean 404s. A pointer advance atomically swaps the served version (and the // `current` symlink) without dropping a request. package edge import ( "archive/tar" "bytes" "compress/gzip" "context" "crypto/ed25519" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "io/fs" "log" "mime" "net/http" "os" "path" "path/filepath" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/carlosframework/platform/internal/apperr" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // siteArtifactFile is the one artifact a static release carries: the gzipped // tar of the site tree (`carlos ship -kind static` packs it via PackDir). const siteArtifactFile = "site.tar.gz" // staticRecheck is how long a resolved channel pointer is trusted before the // handler re-reads it from the store. Tests drop it to 0 (re-check every // request) via the staticSite field or Edge.SetStaticRecheck. const staticRecheck = 60 * time.Second // PackDir packs a site tree into the gzipped tar the static handler expects. // The output is deterministic — sorted paths, zeroed times/uid/gid, fixed // 0644 modes — so re-shipping an unchanged tree produces byte-identical // artifacts (and Ship's immutable-put treats it as an idempotent no-op). // Only regular files are packed: directories are implied by file paths, and // anything else (symlinks, devices) in a site tree is a mistake, not content. func PackDir(dir string) ([]byte, error) { var names []string err := filepath.WalkDir(dir, func(p string, de fs.DirEntry, err error) error { if err != nil { return err } if de.IsDir() { return nil } if !de.Type().IsRegular() { return fmt.Errorf("pack %s: %s is not a regular file", dir, p) } rel, err := filepath.Rel(dir, p) if err != nil { return err } names = append(names, filepath.ToSlash(rel)) return nil }) if err != nil { return nil, err } sort.Strings(names) var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) for _, name := range names { b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) if err != nil { return nil, err } if err := tw.WriteHeader(&tar.Header{ Typeflag: tar.TypeReg, Name: name, Mode: 0o644, Size: int64(len(b)), ModTime: time.Unix(0, 0), // zeroed: determinism over provenance }); err != nil { return nil, err } if _, err := tw.Write(b); err != nil { return nil, err } if name == redirectsFile { // Refused here rather than skipped at the edge, so a rule the // edge cannot use is an error the shipper sees, not a redirect // that silently never happens. if _, err := parseRedirects(b); err != nil { return nil, fmt.Errorf("pack %s: %s: %w", dir, name, err) } } } if err := tw.Close(); err != nil { return nil, err } if err := gz.Close(); err != nil { return nil, err } return buf.Bytes(), nil } // staticSite serves one static route. All mutable state (resolved version, // last pointer check) sits behind mu; requests serve from an immutable // per-version directory, so a swap never races a response in flight. type staticSite struct { // acct is the account sqid whose bucket keys this app's objects live // under — part of every key, and part of the cache path: app names are // unique per ACCOUNT, so two accounts can both have a "docs" static site // and their extracted trees must never share a directory. acct string app, channel string store release.Store pub ed25519.PublicKey cacheDir string // recheck is how long a resolved pointer is trusted; 0 = every request. recheck time.Duration mu sync.Mutex version string // currently served version; "" until first resolve checkedAt time.Time // when the pointer was last read digest string // the served tree's manifest digest name string // the served tree's directory: siteDirName(version, digest) redirects *redirectRules // the served tree's _redirects; nil when it has none // ratchet is #253's per-box ratchet for this process's pointer reads // (Edge.staticRatchet); nil admits every verified pointer. ratchet *release.DigestRatchet } // newStaticHandler builds the serving handler for one static route. The // route's empty Channel defaults to "stable", mirroring `carlos add`. func newStaticHandler(rt registry.Route, store release.Store, pub ed25519.PublicKey, cacheDir string) http.Handler { ch := rt.Channel if ch == "" { ch = "stable" } return &staticSite{ acct: release.RouteAccount(rt), app: rt.App, channel: ch, store: store, pub: pub, cacheDir: cacheDir, recheck: staticRecheck, } } func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) { tree, err := s.currentDir(r.Context()) if err != nil { // Nothing has ever resolved here, so there is no version to name: // this 502 stays unstamped rather than claiming a build it isn't // serving. log.Printf("edge: static %s/%s: %v", s.app, s.channel, err) noteCause(r, apperr.CauseStatic) http.Error(w, "static site unavailable", http.StatusBadGateway) return } // X-Carlos-Version for the static class (issue #112). Binary routes get // this from the route table (edge.go, versionHeader), which the adopt // pass stamps; a static route is never adopted, so the truth lives here // instead — and it is PER-REQUEST truth, the version whose tree answers // THIS request, which is strictly better than a table stamp that can lag // the process it describes. That includes the degraded path: currentDir // returns the CACHED version when the pointer is unreadable, and this // stamps what is actually served, never the pointer's unreachable head. // Set before any body is written, so it lands on the tree's own 404s too // (the handler serving that tree IS a version). w.Header().Set("X-Carlos-Version", tree.version) // A page out of the tree is revalidated before it is reused, and so is // the tree's own 404. With Last-Modified alone a browser GUESSES a // freshness lifetime — typically a tenth of the file's age — and reuses // its copy without asking, so a site last deployed a month ago kept // showing the old page for days after the next deploy (discussion #195). // no-cache is "store it, but ask first"; the ETag serveTree sets is what // makes asking cost a 304. It also keeps these responses out of the // edge's own shared cache, which admits only a positive lifetime — as // they always were, having named none. (net/http strips the header again // from the errors it writes itself, a 416 or a 400: neither is a status a // browser caches on a guess.) w.Header().Set("Cache-Control", "no-cache") s.serveTree(tree, w, r) } // servedTree is what one request is answered from: an extracted tree, the // version it holds, and the rules its _redirects declared. They travel // together so a request never pairs one version's files with another's rules. type servedTree struct { dir, version string redirects *redirectRules } // dir is where the tree named name lives once extracted. func (s *staticSite) dir(name string) string { return filepath.Join(s.appDir(), name) } // siteDirName keys the extracted-site cache by the verified manifest digest // as well as the version (#253, spec section 4.6), so a tree extracted from // other bytes under a reused name is never reused. A bare version name, the // key before this change, never matches. func siteDirName(version, digest string) string { return version + "@" + digest } // appDir is this app's cache root, under its account: app names are unique // per ACCOUNT, so two accounts' "docs" sites must never share a tree. func (s *staticSite) appDir() string { return filepath.Join(s.cacheDir, s.acct, s.app) } // currentDir returns the directory to serve this request from AND the version // that directory holds, re-resolving the channel pointer when the cache window // has lapsed. A resolution or staging failure NEVER takes down a site that was // already serving: the cached version keeps answering and the pointer is // retried next window. Only a handler that has never resolved (nothing safe to // serve) errors out. // // The version comes back with the dir rather than being read off s.version // afterwards so ServeHTTP can stamp X-Carlos-Version without re-taking mu (and // without the window in between, where a concurrent resolve could swap // s.version and make the header describe a different tree than the one served). // The tree's redirect rules come back with it for the same reason. func (s *staticSite) currentDir(ctx context.Context) (servedTree, error) { s.mu.Lock() defer s.mu.Unlock() if s.version != "" && s.recheck > 0 && time.Since(s.checkedAt) < s.recheck { // Inside the trust window: this request is answered from the cached // version, and stamping that version is honest by construction — the // header names what the bytes are, not what the pointer may have // advanced to since the last check. return s.served(), nil } cp, err := release.Channel(ctx, s.store, s.pub, s.acct, s.app, s.channel) if err != nil { return s.stale(fmt.Errorf("resolve channel: %w", err)) } if err := s.ratchet.Admit(s.acct, cp); err != nil { return s.stale(err) } if want := cp.Digest; cp.Version != s.version || (want != "" && want != s.digest) { name, digest, err := s.ensureExtracted(ctx, cp.Version, want) if err != nil { return s.stale(fmt.Errorf("stage %s: %w", cp.Version, err)) } if err := s.pointCurrent(name); err != nil { return s.stale(fmt.Errorf("swap current -> %s: %w", name, err)) } s.version, s.digest, s.name = cp.Version, digest, name s.redirects = s.loadRedirects(s.dir(name), cp.Version) } s.checkedAt = time.Now() return s.served(), nil } // served is the tree currently being served. The caller holds mu. func (s *staticSite) served() servedTree { return servedTree{dir: s.dir(s.name), version: s.version, redirects: s.redirects} } // loadRedirects parses the _redirects at the root of an extracted tree, once // per version swap. A tree without one has no rules. A line that does not // parse is logged and skipped: ship refuses such a tree (PackDir), so only a // tree packed some other way gets here, and its remaining rules still apply. func (s *staticSite) loadRedirects(dir, version string) *redirectRules { data, err := os.ReadFile(filepath.Join(dir, redirectsFile)) if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { log.Printf("edge: static %s/%s: %s %s: %v — serving without its rules", s.app, s.channel, version, redirectsFile, err) return nil } rules, err := parseRedirects(data) if err != nil { log.Printf("edge: static %s/%s: %s %s: %s", s.app, s.channel, version, redirectsFile, strings.ReplaceAll(err.Error(), "\n", "; ")) } return rules } // stale is the degraded path: keep serving the version we already verified // and extracted — and name it, so the response header describes the CACHED // tree rather than the head this handler could not reach — or fail if there // has never been one. func (s *staticSite) stale(err error) (servedTree, error) { if s.version == "" { return servedTree{}, err } log.Printf("edge: static %s/%s: %v — still serving cached %s", s.app, s.channel, err, s.version) s.checkedAt = time.Now() // back off: retry next recheck window, not next request return s.served(), nil } // ensureExtracted makes the version's verified tree exist and returns its // directory name and manifest digest. want is the pointer entry's signed // digest: when set, the manifest must hash to it before anything is // downloaded (#253, spec section 4.6), and either way the tree is keyed by the // digest of the manifest actually used. The tar's sha256 is checked against // that manifest before extraction, and extraction goes to a temp dir renamed // into place, so a directory's existence means "complete and verified". app // and version are path-safe by construction: both came out of a // signature-verified pointer whose fields passed ValidApp/ValidVersion. func (s *staticSite) ensureExtracted(ctx context.Context, version, want string) (string, string, error) { m, raw, err := release.CheckManifestDigest(ctx, s.store, s.acct, s.app, version, want) if err != nil { return "", "", err } digest := release.ManifestDigest(raw) name := siteDirName(version, digest) dst := s.dir(name) if _, err := os.Stat(dst); err == nil { return name, digest, nil // extracted by an earlier check or a sibling handler } if m.Kind != "static" { return "", "", fmt.Errorf("release %s/%s is kind %q, not static", s.app, version, m.Kind) } var art *release.Artifact for _, a := range m.Artifacts { if a.File == siteArtifactFile { art = &a break } } if art == nil { return "", "", fmt.Errorf("release %s/%s has no %s artifact", s.app, version, siteArtifactFile) } data, _, err := release.GetQualified(ctx, s.store, release.ArtifactKey(s.acct, s.app, version, art.File)) if err != nil { return "", "", err } sum := sha256.Sum256(data) if hex.EncodeToString(sum[:]) != art.SHA256 { return "", "", fmt.Errorf("%s sha256 mismatch (manifest %s, stored %s): refusing to extract", siteArtifactFile, art.SHA256, hex.EncodeToString(sum[:])) } if int64(len(data)) != art.Size { return "", "", fmt.Errorf("%s size mismatch (manifest %d, stored %d)", siteArtifactFile, art.Size, len(data)) } appDir := s.appDir() if err := os.MkdirAll(appDir, 0o755); err != nil { return "", "", err } tmp, err := os.MkdirTemp(appDir, ".extract-") if err != nil { return "", "", err } if err := extractSiteTar(data, tmp); err != nil { os.RemoveAll(tmp) return "", "", err } if err := os.Rename(tmp, dst); err != nil { os.RemoveAll(tmp) if _, serr := os.Stat(dst); serr == nil { return name, digest, nil // lost the staging race; the winner's tree is identical } return "", "", err } return name, digest, nil } // pointCurrent atomically swaps the app's `current` symlink to the tree named name // (temp symlink + rename — the same swap versions.go's PointLive uses), so // anything looking at the cache dir sees the version flip as one atomic step. func (s *staticSite) pointCurrent(name string) error { link := filepath.Join(s.appDir(), "current") tmp := link + ".tmp" _ = os.Remove(tmp) // a leftover from a crashed swap if err := os.Symlink(name, tmp); err != nil { return err } if err := os.Rename(tmp, link); err != nil { os.Remove(tmp) return err } return nil } // extractSiteTar unpacks a verified site tar into dst, rejecting anything // that would write outside it: absolute names, ".." traversal, and non-file // entry types (a symlink in a site tar could alias the extraction outside // the cache — PackDir never emits one, so none is ever legitimate). func extractSiteTar(data []byte, dst string) error { gz, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { return fmt.Errorf("site tar: %v", err) } defer gz.Close() tr := tar.NewReader(gz) for { hdr, err := tr.Next() if err == io.EOF { return nil } if err != nil { return fmt.Errorf("site tar: %v", err) } name := filepath.FromSlash(path.Clean(hdr.Name)) if !filepath.IsLocal(name) { return fmt.Errorf("site tar: entry %q escapes the extraction dir", hdr.Name) } switch hdr.Typeflag { case tar.TypeDir: if err := os.MkdirAll(filepath.Join(dst, name), 0o755); err != nil { return err } case tar.TypeReg: p := filepath.Join(dst, name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return err } f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err } if _, err := io.Copy(f, tr); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } case tar.TypeXGlobalHeader: // pax global header: metadata, nothing to write default: return fmt.Errorf("site tar: entry %q has unsupported type %d", hdr.Name, hdr.Typeflag) } } } // serveTree answers one request from an extracted version dir: // http.FileServer semantics with index.html for directories and a clean 404 // (never a directory listing) when neither the file nor an index exists — // after the tree's _redirects (redirects.go) has had its say. A rule applies // only where no file answers the path, unless it is forced. // // It serves the file itself rather than through http.ServeFile, because // ServeFile offers the file's mtime as Last-Modified and answers // If-Modified-Since from it — and an extracted file's mtime is when THIS box // unpacked the tree, which is not a fact about the content. It differs // between edges serving the same release, and it runs backwards on a // rollback: the tree rolled back to was unpacked before the one the browser // holds, so "modified since?" answers no and the browser keeps the build that // was withdrawn. No date is offered, so there is none to compare. // // The validator is the file's content (tagCache). What ServeFile did besides // is kept by hand: the refusal of a ".." element, and the redirect off a // trailing /index.html onto its directory. func (s *staticSite) serveTree(tree servedTree, w http.ResponseWriter, r *http.Request) { if containsDotDot(r.URL.Path) { // The Clean below would resolve it harmlessly inside the tree; this // keeps the answer ServeFile always gave such a path. http.Error(w, "invalid URL path", http.StatusBadRequest) return } // Clean rooted at "/" so no request path can climb out of the tree. p := path.Clean("/" + r.URL.Path) status, rewritten := http.StatusOK, false for { fp := filepath.Join(tree.dir, filepath.FromSlash(p)) fi, err := os.Stat(fp) if err == nil && fi.IsDir() { fp = filepath.Join(fp, "index.html") fi, err = os.Stat(fp) } // The _redirects file is configuration and answers nothing. found := err == nil && !fi.IsDir() && p != "/"+redirectsFile if !rewritten { if act, ok := tree.redirects.resolve(requestHost(r.Host), p, r.URL.Path, r.URL.RawQuery, found); ok { if act.location != "" { http.Redirect(w, r, act.location, act.status) return } // A rewrite serves another file of the tree at this URL. It is // looked up once: rules are not consulted a second time, and a // destination with no file is a plain 404. p, status, rewritten = act.file, act.status, true continue } } if !found { http.NotFound(w, r) return } if !rewritten && strings.HasSuffix(r.URL.Path, "/index.html") { // Relative, as ServeFile wrote it, so it survives any prefix the // request arrived under. loc := "./" if q := r.URL.RawQuery; q != "" { loc += "?" + q } w.Header().Set("Location", loc) w.WriteHeader(http.StatusMovedPermanently) return } s.serveFile(fp, p, fi, status, w, r) return } } // serveFile sends the tree file at fp, whose tree path is p (for logs). A 200 // gets a content tag and net/http's conditional and range handling. Any other // status — a _redirects 404 page — is the file's bytes under that status, // with no validator: ServeContent knows only how to succeed. func (s *staticSite) serveFile(fp, p string, fi os.FileInfo, status int, w http.ResponseWriter, r *http.Request) { f, err := os.Open(fp) if err != nil { // ServeFile's mapping, kept: a file that vanished since the Stat is // a 404, but one that is there and will not open is not a missing // page, and must not read as one in an access log. switch { case errors.Is(err, fs.ErrNotExist): http.NotFound(w, r) case errors.Is(err, fs.ErrPermission): http.Error(w, "403 Forbidden", http.StatusForbidden) default: log.Printf("edge: static %s/%s: open %s: %v", s.app, s.channel, p, err) http.Error(w, "500 Internal Server Error", http.StatusInternalServerError) } return } defer f.Close() if status != http.StatusOK { ctype := mime.TypeByExtension(filepath.Ext(fp)) if ctype == "" { var head [512]byte n, _ := io.ReadFull(f, head[:]) ctype = http.DetectContentType(head[:n]) if _, err := f.Seek(0, io.SeekStart); err != nil { log.Printf("edge: static %s/%s: rewind %s: %v", s.app, s.channel, p, err) http.Error(w, "500 Internal Server Error", http.StatusInternalServerError) return } } w.Header().Set("Content-Type", ctype) w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10)) w.WriteHeader(status) if r.Method != http.MethodHead { io.Copy(w, f) } return } // Stat the OPEN file: its identity is what a remembered tag is checked // against, and it is the file whose bytes are about to be sent. if ofi, err := f.Stat(); err == nil && !ofi.IsDir() { fi = ofi if tag, ctype, err := staticTags.tag(r.Context(), fp, f, fi); err == nil { // The type the tag was computed over is the type sent: set here, // ServeContent leaves it alone. w.Header().Set("ETag", tag) w.Header().Set("Content-Type", ctype) } else { if !errors.Is(err, errTagBusy) && r.Context().Err() == nil { // No tag is the honest answer: the response then carries no // validator and no-cache makes the browser refetch it whole. log.Printf("edge: static %s/%s: tag %s: %v", s.app, s.channel, p, err) } // A failed hash rewinds, but if even that failed the file's // position is unknown and ServeContent would sniff its type // from the middle of it. if _, serr := f.Seek(0, io.SeekStart); serr != nil { log.Printf("edge: static %s/%s: rewind %s: %v", s.app, s.channel, p, serr) http.Error(w, "500 Internal Server Error", http.StatusInternalServerError) return } } } // ServeContent brings Content-Type, ranges and the conditional-request // handling; the zero time is "no Last-Modified, and no date to test". http.ServeContent(w, r, fi.Name(), time.Time{}, f) } // tagCache computes and remembers the entity tag of each extracted file: a // hash of its bytes and of the one thing besides them that decides what the // client receives, its Content-Type. // // The content, and nothing that merely names it. A version name is reused — // a pruned version re-shipped under its old name is a new release // (release.putImmutable), and a hostname can move between two apps that both // have a "v1" — so a tag made of the name would hand a browser a 304 over // bytes it does not hold. Hashing also means identical bytes get one tag on // every edge, and a file a deploy did not touch keeps its tag across it: the // release that changed one page does not make every browser refetch every // asset. // // The type is hashed in because the same bytes can be two representations. // /about served from an extensionless file is sniffed as text/plain; the same // bytes at about/index.html are text/html; and two boxes whose MIME tables // differ type one .md file differently. A 304 carries no Content-Type, so // bytes alone would leave the browser rendering the old one. The type is // resolved here exactly as net/http resolves it — the extension's mapping, // else a sniff of the first 512 bytes — and handed back with the tag, so the // type the tag covers is the type the response carries. // // WEAK, although such a hash is as strong as a validator gets. The edge gzips // compressible responses above this handler and weakens a strong tag when it // does (compress.go, weakenETag) — but only on the 200. A 304 and a HEAD pass // through as they are, so a strong tag would come back weak on the page and // strong on its own revalidation, and a cache may not update the one from the // other. Weak from the start, it reads the same on all three. The cost is // that If-Range falls back to the whole file. // // One cache for the process rather than one per handler: the edge rebuilds // every static handler on each route-table reload, and a per-handler memo // would re-hash every site each time a route anywhere on the box changed. // What makes a shared, long-lived memo safe: // // - An entry is checked against the file it was computed FROM, not the path // it was computed at (os.SameFile, size, mtime). A tree replaced on disk // under a live handler — cache maintenance, then a re-shipped version of // the same name extracted again — is new files, and hashes again. // - A cold file is hashed once however many requests want it: the rest // wait for the one in flight. // - A file over tagInline hashes under a semaphore, tagHashers at a time, // so a sweep of a site's large assets cannot take every core and the disk // at once. Small files never queue behind them: their work is bounded by // their size and they hash inline. // - Nobody waits long. A tag is an optimization, and a request that has // waited `wait` for a hasher or for someone else's hash is served without // one (errTagBusy): correct, uncached, and tried again on the next // request. Only the request doing a hash sees it through, because every // later request needs the result. // - It is bounded: past tagCacheMax entries it is emptied and refills on // demand, which costs hashing and never correctness. type tagCache struct { mu sync.Mutex entries map[string]tagEntry flights map[string]chan struct{} // closed when the hash for that path is done sem chan struct{} wait time.Duration // Evidence and a seam for the tests: files hashed, callers currently // waiting on another's hash, and a hook run inside a hash (nil in // production) so a test can hold one open. hashed atomic.Int64 waiting atomic.Int64 hold func() } type tagEntry struct { fi os.FileInfo tag, ctype string } const ( tagHashers = 4 tagInline = 1 << 20 // files up to this size hash without a hasher slot tagWait = 250 * time.Millisecond tagCacheMax = 1 << 16 ) // errTagBusy: the tag could not be had within the wait budget. The caller // serves the file untagged. var errTagBusy = errors.New("static tag: hashers busy") func newTagCache() *tagCache { return &tagCache{ entries: map[string]tagEntry{}, flights: map[string]chan struct{}{}, sem: make(chan struct{}, tagHashers), wait: tagWait, } } var staticTags = newTagCache() // current reports whether e still describes the file fi is. func (e tagEntry) current(fi os.FileInfo) bool { return e.fi != nil && os.SameFile(e.fi, fi) && e.fi.Size() == fi.Size() && e.fi.ModTime().Equal(fi.ModTime()) } // tag returns the entity tag and Content-Type for the open file f at fp, // hashing it if no current entry exists. f is left positioned at its start. // ctx and the wait budget bound the WAITING — on another request's hash, or // on a free hasher — never a hash in progress. func (c *tagCache) tag(ctx context.Context, fp string, f io.ReadSeeker, fi os.FileInfo) (tag, ctype string, err error) { var budget <-chan time.Time for { c.mu.Lock() if e, ok := c.entries[fp]; ok && e.current(fi) { c.mu.Unlock() return e.tag, e.ctype, nil } if budget == nil { t := time.NewTimer(c.wait) defer t.Stop() budget = t.C } if flight, ok := c.flights[fp]; ok { c.mu.Unlock() c.waiting.Add(1) select { case <-flight: c.waiting.Add(-1) continue // look again: usually a hit, a miss if the file differs case <-ctx.Done(): c.waiting.Add(-1) return "", "", ctx.Err() case <-budget: c.waiting.Add(-1) return "", "", errTagBusy } } done := make(chan struct{}) c.flights[fp] = done c.mu.Unlock() tag, ctype, err = c.hash(ctx, budget, f, fi) c.mu.Lock() if err == nil { if len(c.entries) >= tagCacheMax { c.entries = map[string]tagEntry{} } c.entries[fp] = tagEntry{fi: fi, tag: tag, ctype: ctype} } delete(c.flights, fp) c.mu.Unlock() close(done) return tag, ctype, err } } func (c *tagCache) hash(ctx context.Context, budget <-chan time.Time, f io.ReadSeeker, fi os.FileInfo) (tag, ctype string, err error) { if fi.Size() > tagInline { select { case c.sem <- struct{}{}: defer func() { <-c.sem }() case <-ctx.Done(): return "", "", ctx.Err() case <-budget: return "", "", errTagBusy } } c.hashed.Add(1) if c.hold != nil { c.hold() } // From here the file is being read, and it goes back to its start on // EVERY path out — a failed hash included. The caller serves the file // either way, and net/http sniffs a Content-Type from wherever the file // is positioned. defer func() { if _, serr := f.Seek(0, io.SeekStart); serr != nil && err == nil { tag, ctype, err = "", "", serr } }() // net/http's own rule (serveContent): the extension's type, else a sniff // of the first 512 bytes. var head [512]byte n, err := io.ReadFull(f, head[:]) if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { return "", "", err } ctype = mime.TypeByExtension(filepath.Ext(fi.Name())) if ctype == "" { ctype = http.DetectContentType(head[:n]) } h := sha256.New() io.WriteString(h, ctype) h.Write([]byte{0}) h.Write(head[:n]) if _, err := io.Copy(h, f); err != nil { return "", "", err } return `W/"` + hex.EncodeToString(h.Sum(nil)[:16]) + `"`, ctype, nil } // containsDotDot reports whether a URL path has a ".." element — net/http's // own test, which http.ServeFile applied before this handler served files // itself. func containsDotDot(p string) bool { if !strings.Contains(p, "..") { return false } for _, el := range strings.FieldsFunc(p, func(r rune) bool { return r == '/' || r == '\\' }) { if el == ".." { return true } } return false }