This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
// Package site turns an activated deployment into HTTP responses.
//
// Everything here is built around one property: a request must never observe a
// mixture of two deployments. That is achieved by making the served state an
// immutable snapshot behind an atomic pointer — a handler loads it once, at the
// top, and every subsequent decision in that request comes from the value it
// loaded. Switching versions is one pointer store, so an in-flight request keeps
// reading the deployment it started on until it finishes.
package site
import (
"path"
"time"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/store"
)
// FileEntry is what a request path resolves to: enough to serve the bytes and
// to answer a conditional request, and nothing else.
type FileEntry struct {
Digest cas.Digest
Size int64
}
// Index is the part of a snapshot that costs something to build.
//
// It is separate from Deployment so activation can pay for it *before* it
// changes anything: if the manifest cannot be read or turned into these maps,
// the failure happens while the old deployment is still the active one.
type Index struct {
files map[string]FileEntry
dirs map[string]struct{}
totalBytes int64
}
// NewIndex builds the lookup structures for one manifest.
//
// Dirs holds every directory prefix, which is what lets Resolve tell "no such
// path" apart from "that is a directory, redirect to it with a trailing slash"
// without scanning the file map for prefixes on every miss.
func NewIndex(files []store.FileRow) *Index {
idx := &Index{
files: make(map[string]FileEntry, len(files)),
dirs: make(map[string]struct{}),
}
for _, f := range files {
idx.files[f.Path] = FileEntry{Digest: f.Digest, Size: f.Size}
idx.totalBytes += f.Size
for dir := path.Dir(f.Path); dir != "." && dir != "/"; dir = path.Dir(dir) {
if _, ok := idx.dirs[dir]; ok {
// Every shorter prefix was added with this one, so there is
// nothing left to walk.
break
}
idx.dirs[dir] = struct{}{}
}
}
return idx
}
// Deployment is an immutable snapshot of what a project serves.
//
// Nothing mutates one after NewDeployment returns. That is the whole reason a
// version switch can be a bare pointer store: readers need no synchronisation
// beyond the atomic load that handed them the snapshot.
type Deployment struct {
ID string
ProjectID int64
// Dir is the assembled tree, kept for the webroot symlink and for
// operators. It is empty under assemble_mode=none and is never used to
// serve a request — content comes from the CAS by digest.
Dir string
// CreatedAt is the Last-Modified of every file in the deployment. The CAS
// file's own mtime would be wrong: blobs are shared across projects and
// versions, so their mtime means nothing here and would leak when some
// other project first uploaded the same bytes.
CreatedAt time.Time
ActivatedAt time.Time
FileCount int
TotalBytes int64
idx *Index
}
// NewDeployment pairs a row with an already-built index. It allocates one
// struct and copies no maps, so activation can call it after the database
// commit without any risk of failing there.
func NewDeployment(dep *store.Deployment, idx *Index, dir string) *Deployment {
d := &Deployment{
ID: dep.PublicID,
ProjectID: dep.ProjectID,
Dir: dir,
CreatedAt: dep.CreatedAt,
FileCount: len(idx.files),
TotalBytes: idx.totalBytes,
idx: idx,
}
if dep.ActivatedAt != nil {
d.ActivatedAt = *dep.ActivatedAt
}
return d
}
// Lookup finds one file by its slash-separated path within the deployment.
func (d *Deployment) Lookup(rel string) (FileEntry, bool) {
e, ok := d.idx.files[rel]
return e, ok
}
// IsDir reports whether rel is a directory prefix of some file.
func (d *Deployment) IsDir(rel string) bool {
_, ok := d.idx.dirs[rel]
return ok
}
+176
View File
@@ -0,0 +1,176 @@
package site
import (
"context"
"maps"
"sync"
"sync/atomic"
"github.com/iceBear67/simplepages/internal/store"
)
// ProjectConfig is the serving-time part of a project row, copied out so the
// read path never touches the database. It is replaced wholesale, never edited
// in place, so a reader always sees one consistent set of settings.
type ProjectConfig struct {
IndexFile string
NotFoundFile string
SPAFallback bool
CacheControl string
}
// ConfigOf extracts what serving needs from a project row.
func ConfigOf(p *store.Project) *ProjectConfig {
return &ProjectConfig{
IndexFile: p.IndexFile,
NotFoundFile: p.NotFoundFile,
SPAFallback: p.SPAFallback,
CacheControl: p.CacheControl,
}
}
// Project is one project's live serving state.
//
// Both fields are atomic pointers to immutable values, so every read on the
// serving path is one atomic load and nothing else. Writers are already
// serialised per project by the deploy service's lock; the atomics are here for
// the readers, not for mutual exclusion.
type Project struct {
ID int64
Name string
cfg atomic.Pointer[ProjectConfig]
active atomic.Pointer[Deployment]
}
// NewProject builds a registry entry with no deployment activated yet.
func NewProject(p *store.Project) *Project {
sp := &Project{ID: p.ID, Name: p.Name}
sp.cfg.Store(ConfigOf(p))
return sp
}
// Active is the deployment this project is serving, or nil if it has never
// activated one.
//
// A request handler calls this exactly once, at the top, and uses the value it
// gets for the rest of the request. Calling it a second time within one request
// is a bug: the two loads could straddle an activation and produce a response
// assembled from two different versions, which is the exact failure this whole
// program exists to prevent.
func (p *Project) Active() *Deployment { return p.active.Load() }
// Config is the project's serving settings.
func (p *Project) Config() *ProjectConfig { return p.cfg.Load() }
// Activate publishes a snapshot. This single store is the version switch.
func (p *Project) Activate(d *Deployment) { p.active.Store(d) }
// SetConfig swaps in new serving settings.
func (p *Project) SetConfig(c *ProjectConfig) { p.cfg.Store(c) }
// Registry maps project names to their live state.
//
// Reads are overwhelmingly more common than writes — every static request does
// one, while the set of projects changes on the order of minutes to days — so
// the map is copy-on-write behind an atomic pointer: readers get a lock-free,
// allocation-free lookup against a consistent snapshot of the whole set, and
// writers pay O(n) to clone it. A RWMutex would put one shared cache line in
// every request's path for no benefit at this write rate.
//
// Note the layering: activating a deployment does *not* rebuild this map. It
// stores into the Project the map already points at, so the copy is reserved for
// changes to the project set itself.
type Registry struct {
mu sync.Mutex // serialises writers only; readers never take it
byName atomic.Pointer[map[string]*Project]
}
func NewRegistry() *Registry {
r := &Registry{}
r.byName.Store(&map[string]*Project{})
return r
}
// Lookup finds a project by the name in the URL.
func (r *Registry) Lookup(name string) (*Project, bool) {
p, ok := (*r.byName.Load())[name]
return p, ok
}
// Len is the number of projects the registry knows about.
func (r *Registry) Len() int { return len(*r.byName.Load()) }
// Projects returns the current entries in no particular order.
func (r *Registry) Projects() []*Project {
m := *r.byName.Load()
out := make([]*Project, 0, len(m))
for _, p := range m {
out = append(out, p)
}
return out
}
// ResolveProject satisfies auth.ProjectResolver, which lets the ownership guard
// on the hot deployment endpoints answer from memory instead of querying.
//
// It is only safe in that role because the registry is fully built from the
// database before the listeners start: a partially populated registry would
// report someone's own project as unknown.
func (r *Registry) ResolveProject(_ context.Context, name string) (int64, error) {
p, ok := r.Lookup(name)
if !ok {
return 0, store.ErrNotFound
}
return p.ID, nil
}
// Replace swaps in a whole new project set, which is how startup publishes the
// registry it built from the database.
func (r *Registry) Replace(ps []*Project) {
m := make(map[string]*Project, len(ps))
for _, p := range ps {
m[p.Name] = p
}
r.mu.Lock()
defer r.mu.Unlock()
r.byName.Store(&m)
}
// Put adds or updates a project and returns its live entry.
//
// An entry that is already present is kept rather than rebuilt, so a settings
// change does not disturb the deployment the project is currently serving.
func (r *Registry) Put(p *store.Project) *Project {
r.mu.Lock()
defer r.mu.Unlock()
old := *r.byName.Load()
if sp, ok := old[p.Name]; ok && sp.ID == p.ID {
sp.SetConfig(ConfigOf(p))
return sp
}
sp := NewProject(p)
next := maps.Clone(old)
if next == nil {
next = make(map[string]*Project, 1)
}
next[p.Name] = sp
r.byName.Store(&next)
return sp
}
// Delete drops a project. Requests for it start 404ing as soon as the new map
// is stored; requests already reading a snapshot of it finish normally.
func (r *Registry) Delete(name string) {
r.mu.Lock()
defer r.mu.Unlock()
old := *r.byName.Load()
if _, ok := old[name]; !ok {
return
}
next := maps.Clone(old)
delete(next, name)
r.byName.Store(&next)
}
+149
View File
@@ -0,0 +1,149 @@
package site
import (
"context"
"errors"
"sync"
"testing"
"github.com/iceBear67/simplepages/internal/store"
)
func TestRegistryLookupAndDelete(t *testing.T) {
r := NewRegistry()
if _, ok := r.Lookup("demo"); ok {
t.Fatal("an empty registry answered a lookup")
}
if r.Len() != 0 {
t.Fatalf("Len = %d on an empty registry", r.Len())
}
sp := r.Put(&store.Project{ID: 7, Name: "demo", IndexFile: "index.html"})
got, ok := r.Lookup("demo")
if !ok || got != sp {
t.Fatal("Put did not publish the project it returned")
}
if got.ID != 7 || got.Name != "demo" {
t.Errorf("entry = %+v", got)
}
if r.Len() != 1 {
t.Errorf("Len = %d, want 1", r.Len())
}
r.Delete("demo")
if _, ok := r.Lookup("demo"); ok {
t.Error("a deleted project still resolves")
}
r.Delete("demo") // deleting twice is not an error
r.Delete("never-existed")
}
// A settings change must not disturb what the project is currently serving: the
// entry is updated in place rather than rebuilt, so the active pointer survives.
func TestRegistryPutKeepsTheActiveDeployment(t *testing.T) {
r := NewRegistry()
p := &store.Project{ID: 1, Name: "demo", IndexFile: "index.html"}
sp := r.Put(p)
d := fixture("index.html")
sp.Activate(d)
p2 := &store.Project{ID: 1, Name: "demo", IndexFile: "main.html", SPAFallback: true}
again := r.Put(p2)
if again != sp {
t.Fatal("Put replaced the live entry instead of updating it")
}
if again.Active() != d {
t.Fatal("updating the settings dropped the active deployment")
}
if cfg := again.Config(); cfg.IndexFile != "main.html" || !cfg.SPAFallback {
t.Errorf("config = %+v, want the new settings", cfg)
}
}
// A project recreated under the same name is a different project, so its entry
// must start empty rather than inherit the old one's deployment.
func TestRegistryPutReplacesAnEntryWithADifferentID(t *testing.T) {
r := NewRegistry()
sp := r.Put(&store.Project{ID: 1, Name: "demo"})
sp.Activate(fixture("index.html"))
again := r.Put(&store.Project{ID: 2, Name: "demo"})
if again == sp {
t.Fatal("a different project id reused the old entry")
}
if again.Active() != nil {
t.Error("the new project inherited the old project's deployment")
}
}
func TestRegistryReplace(t *testing.T) {
r := NewRegistry()
r.Put(&store.Project{ID: 1, Name: "gone"})
r.Replace([]*Project{
NewProject(&store.Project{ID: 2, Name: "a"}),
NewProject(&store.Project{ID: 3, Name: "b"}),
})
if _, ok := r.Lookup("gone"); ok {
t.Error("Replace kept a project that is not in the new set")
}
if r.Len() != 2 {
t.Fatalf("Len = %d, want 2", r.Len())
}
names := map[string]bool{}
for _, p := range r.Projects() {
names[p.Name] = true
}
if !names["a"] || !names["b"] {
t.Errorf("Projects = %v", names)
}
}
// The API's ownership guard compares resolved ids, so this is the lookup that
// decides whether one project's key can touch another's deployment.
func TestRegistryResolveProject(t *testing.T) {
r := NewRegistry()
r.Put(&store.Project{ID: 42, Name: "demo"})
id, err := r.ResolveProject(context.Background(), "demo")
if err != nil || id != 42 {
t.Fatalf("ResolveProject = (%d, %v), want (42, nil)", id, err)
}
if _, err := r.ResolveProject(context.Background(), "other"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("ResolveProject on an unknown name = %v, want ErrNotFound", err)
}
}
// Readers take no lock at all, so this is worth running under -race: a writer
// mutating the map in place instead of cloning it would show up here.
func TestRegistryConcurrentReadersAndWriters(t *testing.T) {
r := NewRegistry()
r.Put(&store.Project{ID: 1, Name: "stable"})
stop := make(chan struct{})
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if p, ok := r.Lookup("stable"); !ok || p.ID != 1 {
t.Error("the stable project vanished while other projects changed")
return
}
r.Lookup("churn")
r.Len()
}
}()
}
for i := range 500 {
r.Put(&store.Project{ID: int64(i + 2), Name: "churn"})
r.Delete("churn")
}
close(stop)
wg.Wait()
}
+129
View File
@@ -0,0 +1,129 @@
package site
import (
"net/http"
"path"
"strings"
"github.com/iceBear67/simplepages/internal/pathutil"
)
// Result is what a request path resolved to. It is a value, not a response:
// Resolve performs no I/O and touches nothing, which is what makes the routing
// rules exhaustively testable as a table.
type Result struct {
Entry FileEntry
// Name is the logical path within the deployment, which decides the
// Content-Type. It is never a filesystem path.
Name string
// Status is 200, 301, 400 or 404. A 404 may still carry an Entry, which is
// the project's custom not-found document.
Status int
// Location is the site-absolute path to redirect to, set when Status is 301.
// It is unescaped; the caller is responsible for building the header value.
Location string
}
// Resolve maps a request onto a file in the deployment.
//
// project and rest are what splitTilde produced, so rest keeps its leading
// slash and is empty for "/~proj". accept is the request's Accept header, which
// only matters for the SPA fallback.
func Resolve(d *Deployment, cfg *ProjectConfig, project, rest, accept string) Result {
// "/~proj" must become "/~proj/" before anything else: without the trailing
// slash every relative link in the page would resolve one level too high.
if rest == "" {
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + "/"}
}
// splitTilde always leaves the leading slash on, so this cannot happen from
// the serving path. Enforcing it anyway is what keeps every "/~" + project +
// … below a path *inside* the project: without it a rest of "x" would build
// "/~demox" and send the client to a different project entirely.
if rest[0] != '/' {
return Result{Status: http.StatusBadRequest}
}
// Canonicalise. The trailing slash survives Clean deliberately — it is the
// difference between asking for a directory and asking for a file, and
// dropping it here would redirect "/dir/" to "/dir" only for step 6 to
// redirect it back.
trailing := strings.HasSuffix(rest, "/")
clean := path.Clean(rest)
canon := clean
if trailing && clean != "/" {
canon += "/"
}
if canon != rest {
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + canon}
}
rel := strings.TrimPrefix(clean, "/")
if rel == "" {
if cfg.IndexFile == "" {
return Result{Status: http.StatusNotFound}
}
rel = cfg.IndexFile
}
// Clean has already removed any "..", so this is defence in depth rather
// than the primary guard — but it is also what rejects NUL and control
// bytes, and a path that cannot be a manifest entry cannot be a hit.
if err := pathutil.Validate(rel); err != nil {
return Result{Status: http.StatusBadRequest}
}
if e, ok := d.Lookup(rel); ok {
if trailing && clean != "/" {
// "/page.html/" names a file with a directory's URL. Serving it there
// would make every relative link inside resolve one level too deep,
// and would cache the same bytes under two URLs.
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + clean}
}
return Result{Entry: e, Name: rel, Status: http.StatusOK}
}
if d.IsDir(rel) {
if !trailing {
// Redirect rather than serve the index directly, again so relative
// links inside the page resolve against the directory.
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + clean + "/"}
}
if idx := path.Join(rel, cfg.IndexFile); pathutil.Validate(idx) == nil {
if e, ok := d.Lookup(idx); ok {
return Result{Entry: e, Name: idx, Status: http.StatusOK}
}
}
}
// SPA fallback, gated on Accept. Without that gate a missing
// /assets/app.js would come back as HTML with status 200, and the failure
// surfaces later as "Unexpected token '<'" somewhere entirely unrelated.
if cfg.SPAFallback && acceptsHTML(accept) {
if e, ok := d.Lookup(cfg.IndexFile); ok {
return Result{Entry: e, Name: cfg.IndexFile, Status: http.StatusOK}
}
}
if cfg.NotFoundFile != "" {
if e, ok := d.Lookup(cfg.NotFoundFile); ok {
return Result{Entry: e, Name: cfg.NotFoundFile, Status: http.StatusNotFound}
}
}
return Result{Status: http.StatusNotFound}
}
// acceptsHTML reports whether the client asked for HTML specifically.
//
// "*/*" does not count. A browser navigating to a page sends text/html; a
// fetch() for a script or a JSON document sends */* or something narrower, and
// those are exactly the requests that must keep getting a 404.
func acceptsHTML(accept string) bool {
for len(accept) > 0 {
var field string
field, accept, _ = strings.Cut(accept, ",")
media, _, _ := strings.Cut(field, ";")
if strings.EqualFold(strings.TrimSpace(media), "text/html") {
return true
}
}
return false
}
+445
View File
@@ -0,0 +1,445 @@
package site
import (
"io/fs"
"net/http"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/store"
)
// siteFiles is one deployment covering every shape Resolve has to tell apart: a
// root index, a directory that has an index, a directory that does not, a custom
// error document, and a non-ASCII name.
var siteFiles = []string{
"index.html",
"404.html",
"assets/app.js",
"docs/index.html",
"docs/deep/page.html",
"noindex/data.json",
"文档/说明.html",
}
func fixture(paths ...string) *Deployment {
rows := make([]store.FileRow, len(paths))
for i, p := range paths {
rows[i] = store.FileRow{Path: p, Digest: cas.Sum([]byte(p)), Size: int64(len(p))}
}
dep := &store.Deployment{PublicID: "dpl_test", ProjectID: 1, CreatedAt: time.Unix(1700000000, 0).UTC()}
return NewDeployment(dep, NewIndex(rows), "")
}
// plain is the default project: an index, no error document, no fallback.
func plain() *ProjectConfig { return &ProjectConfig{IndexFile: "index.html"} }
func withNotFound() *ProjectConfig {
c := plain()
c.NotFoundFile = "404.html"
return c
}
func withSPA() *ProjectConfig {
c := plain()
c.SPAFallback = true
return c
}
func TestResolve(t *testing.T) {
d := fixture(siteFiles...)
tests := []struct {
name string
cfg *ProjectConfig
rest string
accept string
status int
file string // Result.Name
location string
}{
// ------------------------------------------------ canonicalisation
{
name: "bare project name gets a trailing slash",
cfg: plain(),
rest: "",
status: http.StatusMovedPermanently,
location: "/~demo/",
},
{
name: "root serves the index",
cfg: plain(),
rest: "/",
status: http.StatusOK,
file: "index.html",
},
{
name: "exact hit",
cfg: plain(),
rest: "/assets/app.js",
status: http.StatusOK,
file: "assets/app.js",
},
{
name: "unicode path",
cfg: plain(),
rest: "/文档/说明.html",
status: http.StatusOK,
file: "文档/说明.html",
},
{
name: "double slash is collapsed",
cfg: plain(),
rest: "//assets//app.js",
status: http.StatusMovedPermanently,
location: "/~demo/assets/app.js",
},
{
name: "dot segment is removed",
cfg: plain(),
rest: "/./assets/app.js",
status: http.StatusMovedPermanently,
location: "/~demo/assets/app.js",
},
{
// The decoded form of %2e%2e%2f: it arrives here unredirected by the
// mux, and Clean resolves it before anything is looked up. It cannot
// reach outside the deployment because the result is only ever a key
// into a map.
name: "parent segments are resolved, not followed",
cfg: plain(),
rest: "/assets/../index.html",
status: http.StatusMovedPermanently,
location: "/~demo/index.html",
},
{
name: "parent segments above the root land at the root",
cfg: plain(),
rest: "/../../etc/passwd",
status: http.StatusMovedPermanently,
location: "/~demo/etc/passwd",
},
{
name: "a file asked for with a directory's URL",
cfg: plain(),
rest: "/index.html/",
status: http.StatusMovedPermanently,
location: "/~demo/index.html",
},
// ------------------------------------------------------ directories
{
name: "directory without a trailing slash redirects",
cfg: plain(),
rest: "/docs",
status: http.StatusMovedPermanently,
location: "/~demo/docs/",
},
{
name: "directory with a trailing slash serves its index",
cfg: plain(),
rest: "/docs/",
status: http.StatusOK,
file: "docs/index.html",
},
{
name: "intermediate directory redirects too",
cfg: plain(),
rest: "/docs/deep",
status: http.StatusMovedPermanently,
location: "/~demo/docs/deep/",
},
{
name: "directory with no index is a 404, not a listing",
cfg: plain(),
rest: "/noindex/",
status: http.StatusNotFound,
},
{
name: "nothing there at all",
cfg: plain(),
rest: "/nope.txt",
status: http.StatusNotFound,
},
{
name: "an empty index file setting leaves the root a 404",
cfg: &ProjectConfig{},
rest: "/",
status: http.StatusNotFound,
},
// --------------------------------------------------- invalid paths
{
name: "NUL byte",
cfg: plain(),
rest: "/index\x00.html",
status: http.StatusBadRequest,
},
{
name: "control character",
cfg: plain(),
rest: "/index\n.html",
status: http.StatusBadRequest,
},
{
name: "backslash",
cfg: plain(),
rest: "/assets\\app.js",
status: http.StatusBadRequest,
},
{
name: "invalid UTF-8",
cfg: plain(),
rest: "/\xff\xfe.html",
status: http.StatusBadRequest,
},
{
name: "path longer than the limit",
cfg: plain(),
rest: "/" + strings.Repeat("a", 4097),
status: http.StatusBadRequest,
},
{
name: "segment longer than the limit",
cfg: plain(),
rest: "/" + strings.Repeat("b", 256) + "/x",
status: http.StatusBadRequest,
},
// ------------------------------------------------- custom 404 page
{
name: "the error document is served with a 404 status",
cfg: withNotFound(),
rest: "/nope.txt",
status: http.StatusNotFound,
file: "404.html",
},
{
name: "an error document that is not in the manifest is skipped",
cfg: &ProjectConfig{IndexFile: "index.html", NotFoundFile: "missing.html"},
rest: "/nope.txt",
status: http.StatusNotFound,
},
{
name: "a directory with no index falls to the error document",
cfg: withNotFound(),
rest: "/noindex/",
status: http.StatusNotFound,
file: "404.html",
},
// ---------------------------------------------------- SPA fallback
{
name: "a navigation gets the app shell",
cfg: withSPA(),
rest: "/some/client/route",
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
status: http.StatusOK,
file: "index.html",
},
{
// The whole point of the Accept gate: without it a missing script
// comes back as HTML with a 200 and fails as a syntax error in some
// unrelated place.
name: "a missing script still 404s",
cfg: withSPA(),
rest: "/assets/missing.js",
accept: "*/*",
status: http.StatusNotFound,
},
{
name: "no Accept header at all is not a navigation",
cfg: withSPA(),
rest: "/some/client/route",
status: http.StatusNotFound,
},
{
name: "html anywhere in the list counts",
cfg: withSPA(),
rest: "/some/client/route",
accept: "application/json;q=0.9, text/html;q=0.8",
status: http.StatusOK,
file: "index.html",
},
{
name: "with the fallback off a navigation 404s",
cfg: plain(),
rest: "/some/client/route",
accept: "text/html",
status: http.StatusNotFound,
},
{
name: "the fallback does not rescue an invalid path",
cfg: withSPA(),
rest: "/bad\x00path",
accept: "text/html",
status: http.StatusBadRequest,
},
{
// A directory redirect outranks the fallback: the URL is real, it just
// needs its slash.
name: "the fallback does not swallow a directory redirect",
cfg: withSPA(),
rest: "/docs",
accept: "text/html",
status: http.StatusMovedPermanently,
location: "/~demo/docs/",
},
{
name: "the error document wins over nothing when the shell is missing",
cfg: &ProjectConfig{IndexFile: "absent.html", NotFoundFile: "404.html", SPAFallback: true},
rest: "/some/client/route",
accept: "text/html",
status: http.StatusNotFound,
file: "404.html",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Resolve(d, tc.cfg, "demo", tc.rest, tc.accept)
if got.Status != tc.status {
t.Errorf("status = %d, want %d", got.Status, tc.status)
}
if got.Name != tc.file {
t.Errorf("name = %q, want %q", got.Name, tc.file)
}
if got.Location != tc.location {
t.Errorf("location = %q, want %q", got.Location, tc.location)
}
if tc.file != "" && got.Entry.Digest != cas.Sum([]byte(tc.file)) {
t.Errorf("entry does not belong to %s", tc.file)
}
if tc.file == "" && got.Entry != (FileEntry{}) {
t.Errorf("a response with no file carries an entry: %+v", got.Entry)
}
})
}
}
// A redirect must always be a step towards a terminal answer. A pair that
// bounced between two locations would be an infinite loop in a browser.
func TestResolveRedirectsConverge(t *testing.T) {
d := fixture(siteFiles...)
cfgs := map[string]*ProjectConfig{"plain": plain(), "spa": withSPA(), "404": withNotFound()}
paths := []string{
"", "/", "//", "/.", "/..", "/docs", "/docs/", "/docs//deep", "/docs/deep",
"/index.html", "/index.html/", "/./docs/../docs/", "/noindex", "/nope",
"/文档", "/文档/",
}
for name, cfg := range cfgs {
for _, p := range paths {
rest := p
for hop := 0; ; hop++ {
if hop > 4 {
t.Errorf("%s %q: still redirecting after %d hops", name, p, hop)
break
}
res := Resolve(d, cfg, "demo", rest, "text/html")
if res.Status != http.StatusMovedPermanently {
break
}
next, ok := strings.CutPrefix(res.Location, "/~demo")
if !ok {
t.Fatalf("%s %q: redirect escaped the project: %q", name, p, res.Location)
break
}
if next == rest {
t.Fatalf("%s %q: redirects to itself", name, p)
}
rest = next
}
}
}
}
// Whatever a request asks for, the answer either names a path that could have
// been a manifest entry or names nothing at all. That is what keeps the serving
// path from ever deriving a filesystem name from user input.
func FuzzResolve(f *testing.F) {
d := fixture(siteFiles...)
cfg := &ProjectConfig{IndexFile: "index.html", NotFoundFile: "404.html", SPAFallback: true}
for _, s := range []string{
"", "/", "/index.html", "/../../etc/passwd", "//", "/docs/", "/\x00",
"/文档/说明.html", "/a/b/c/../../..", "/.git/config",
} {
f.Add(s, "text/html")
}
f.Fuzz(func(t *testing.T, rest, accept string) {
res := Resolve(d, cfg, "demo", rest, accept)
switch res.Status {
case http.StatusOK, http.StatusNotFound:
if res.Name == "" {
return
}
if !fs.ValidPath(res.Name) {
t.Fatalf("resolved %q to the invalid name %q", rest, res.Name)
}
if _, ok := d.Lookup(res.Name); !ok {
t.Fatalf("resolved %q to %q, which is not in the manifest", rest, res.Name)
}
case http.StatusMovedPermanently:
if !strings.HasPrefix(res.Location, "/~demo/") && res.Location != "/~demo" {
t.Fatalf("resolved %q to a location outside the project: %q", rest, res.Location)
}
if res.Name != "" {
t.Fatalf("a redirect for %q also named a file: %q", rest, res.Name)
}
case http.StatusBadRequest:
if res.Name != "" {
t.Fatalf("a rejection for %q also named a file: %q", rest, res.Name)
}
default:
t.Fatalf("resolved %q to the unexpected status %d", rest, res.Status)
}
})
}
func TestAcceptsHTML(t *testing.T) {
tests := []struct {
accept string
want bool
}{
{"", false},
{"*/*", false}, // a fetch() with no opinion is not a navigation
{"text/*", false}, // and neither is a wildcard subtype
{"application/json", false},
{"text/plain", false},
{"text/htmlx", false},
{"text/html", true},
{"TEXT/HTML", true},
{"text/html;charset=utf-8", true},
{"text/html, */*", true},
{"application/json, text/html;q=0.1", true},
{" text/html ", true},
{"application/xhtml+xml,text/html", true},
}
for _, tc := range tests {
if got := acceptsHTML(tc.accept); got != tc.want {
t.Errorf("acceptsHTML(%q) = %v, want %v", tc.accept, got, tc.want)
}
}
}
func TestIndexRecordsEveryDirectoryPrefix(t *testing.T) {
d := fixture("a/b/c/d.txt", "top.txt")
for _, dir := range []string{"a", "a/b", "a/b/c"} {
if !d.IsDir(dir) {
t.Errorf("%q is not recorded as a directory", dir)
}
}
for _, notDir := range []string{"", ".", "/", "a/b/c/d.txt", "top.txt", "a/b/c/d"} {
if d.IsDir(notDir) {
t.Errorf("%q is recorded as a directory", notDir)
}
}
if d.FileCount != 2 {
t.Errorf("FileCount = %d, want 2", d.FileCount)
}
if want := int64(len("a/b/c/d.txt") + len("top.txt")); d.TotalBytes != want {
t.Errorf("TotalBytes = %d, want %d", d.TotalBytes, want)
}
}
+36
View File
@@ -0,0 +1,36 @@
package site
import "strings"
// splitTilde splits a site URL path into its project and the rest.
//
// "/~proj" -> ("proj", "", true)
// "/~proj/" -> ("proj", "/", true)
// "/~proj/a/b" -> ("proj", "/a/b", true)
// "/", "/x", "/~" -> ("", "", false)
//
// The remainder keeps its leading slash, because its absence is what
// distinguishes "/~proj" — which has to be redirected before relative links
// inside the page can resolve — from "/~proj/".
//
// This is hand-parsed rather than expressed as a ServeMux pattern because
// net/http rejects "/~{project}/{path...}": its wildcards must start a path
// segment. Registering "/" instead also keeps the mux's built-in ".." and "//"
// redirects, though those are a convenience and not a defence — a
// percent-encoded traversal arrives here already decoded and unredirected, so
// Resolve does its own normalisation.
func splitTilde(urlPath string) (project, rest string, ok bool) {
if !strings.HasPrefix(urlPath, "/~") {
return "", "", false
}
rest = urlPath[len("/~"):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
project, rest = rest[:i], rest[i:]
} else {
project, rest = rest, ""
}
if project == "" {
return "", "", false
}
return project, rest, true
}
+56
View File
@@ -0,0 +1,56 @@
package site
import "testing"
func TestSplitTilde(t *testing.T) {
tests := []struct {
path string
project string
rest string
ok bool
}{
{"/~proj", "proj", "", true},
{"/~proj/", "proj", "/", true},
{"/~proj/a/b", "proj", "/a/b", true},
{"/~proj/a/b/", "proj", "/a/b/", true},
{"/~proj//a", "proj", "//a", true},
{"/~p", "p", "", true},
{"/~proj/~other/x", "proj", "/~other/x", true},
// A percent-encoded traversal arrives here already decoded; the project
// still ends at the first slash, and Resolve cleans the remainder.
{"/~proj/../../etc/passwd", "proj", "/../../etc/passwd", true},
// Not a site request at all.
{"", "", "", false},
{"/", "", "", false},
{"/x", "", "", false},
{"/~", "", "", false},
{"/~/", "", "", false},
{"~proj/", "", "", false},
{"//~proj/", "", "", false},
{"/favicon.ico", "", "", false},
{"/api/v1/projects", "", "", false},
}
for _, tc := range tests {
project, rest, ok := splitTilde(tc.path)
if project != tc.project || rest != tc.rest || ok != tc.ok {
t.Errorf("splitTilde(%q) = (%q, %q, %v), want (%q, %q, %v)",
tc.path, project, rest, ok, tc.project, tc.rest, tc.ok)
}
}
}
// The project name never contains a slash, which is what makes "~" + name a
// single entry inside the webroot and keeps a redirect built from it inside the
// project's own prefix.
func TestSplitTildeProjectIsOneSegment(t *testing.T) {
for _, p := range []string{"/~a/b", "/~a//b", "/~a/../b", "/~a/b/c/d"} {
project, _, ok := splitTilde(p)
if !ok {
t.Fatalf("splitTilde(%q) refused a site path", p)
}
if project != "a" {
t.Errorf("splitTilde(%q) project = %q, want %q", p, project, "a")
}
}
}
+143
View File
@@ -0,0 +1,143 @@
package site
import (
"errors"
"io"
"log/slog"
"mime"
"net/http"
"net/url"
"path"
"strconv"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/httpx"
)
// Handler serves the active deployment of every project.
//
// Content comes from the content store by digest, never from the assembled
// directory. That is a security decision as much as a performance one: the only
// filesystem path this path ever builds is cas/<2>/<2>/<64 hex>, derived from a
// [32]byte that came out of a map lookup. No user-controlled string reaches the
// filesystem at all, so traversal on the read path is not defended against —
// it is structurally impossible.
type Handler struct {
Registry *Registry
CAS *cas.Store
Log *slog.Logger
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
h.fail(w, http.StatusMethodNotAllowed, "method not allowed\n")
return
}
project, rest, ok := splitTilde(r.URL.Path)
if !ok {
h.fail(w, http.StatusNotFound, "no site is served at this path; sites live under /~project/\n")
return
}
p, ok := h.Registry.Lookup(project)
if !ok {
h.fail(w, http.StatusNotFound, "no such project\n")
return
}
httpx.LogAttr(r.Context(), "project", project)
// Loaded exactly once. Every decision below uses this snapshot, so an
// activation that lands mid-request cannot split the response across two
// versions. Calling p.Active() again anywhere in this function would
// reintroduce precisely the failure this program exists to prevent.
d := p.Active()
if d == nil {
h.fail(w, http.StatusServiceUnavailable, "this project has no active deployment\n")
return
}
httpx.LogAttr(r.Context(), "deployment", d.ID)
cfg := p.Config()
res := Resolve(d, cfg, project, rest, r.Header.Get("Accept"))
switch {
case res.Status == http.StatusMovedPermanently:
// url.URL re-escapes the path, so a project or file name that needed
// escaping in the request does not arrive raw in the Location header.
loc := url.URL{Path: res.Location, RawQuery: r.URL.RawQuery}
w.Header().Set("X-Content-Type-Options", "nosniff")
http.Redirect(w, r, loc.String(), http.StatusMovedPermanently)
case res.Status == http.StatusBadRequest:
h.fail(w, http.StatusBadRequest, "bad request path\n")
case res.Name == "":
h.fail(w, res.Status, "404 page not found\n")
default:
h.serveEntry(w, r, d, cfg, res)
}
}
func (h *Handler) serveEntry(w http.ResponseWriter, r *http.Request, d *Deployment, cfg *ProjectConfig, res Result) {
f, err := h.CAS.Open(res.Entry.Digest)
if err != nil {
if errors.Is(err, cas.ErrNotFound) {
// The manifest says this blob exists and the store disagrees. GC's
// grace period is supposed to make this unreachable, so it means
// either a bug or an operator who cleared the store by hand.
h.Log.ErrorContext(r.Context(), "blob missing from the content store",
"digest", res.Entry.Digest, "deployment", d.ID, "path", res.Name)
h.fail(w, http.StatusNotFound, "404 page not found\n")
return
}
h.Log.ErrorContext(r.Context(), "opening blob", "err", err, "deployment", d.ID, "path", res.Name)
h.fail(w, http.StatusInternalServerError, "internal server error\n")
return
}
defer f.Close()
head := w.Header()
head.Set("X-Content-Type-Options", "nosniff")
// The type comes from the logical name, not the CAS path, which has no
// extension at all.
ctype := mime.TypeByExtension(path.Ext(res.Name))
if ctype == "" {
ctype = "application/octet-stream"
}
head.Set("Content-Type", ctype)
if cfg.CacheControl != "" {
head.Set("Cache-Control", cfg.CacheControl)
}
if cfg.SPAFallback {
// With the fallback on, one URL can answer with the app shell or with a
// 404 depending on Accept, so a shared cache must key on it.
head.Set("Vary", "Accept")
}
if res.Status != http.StatusOK {
// The custom 404 document. ServeContent always writes 200, so this one
// is written by hand; range requests for an error page are not worth
// the machinery.
head.Set("Content-Length", strconv.FormatInt(res.Entry.Size, 10))
w.WriteHeader(res.Status)
if r.Method != http.MethodHead {
io.Copy(w, f)
}
return
}
// A strong validator: the digest *is* the content, so a matching ETag
// cannot be a lie. With it set, ServeContent handles If-None-Match,
// If-Modified-Since, If-Range, Range and multipart ranges by itself.
head.Set("ETag", `"sha256:`+res.Entry.Digest.String()+`"`)
// The empty name keeps ServeContent from sniffing: the type is already set.
// The modtime is the deployment's, never the blob's — blobs are shared
// across projects, so their mtime says when some unrelated project first
// uploaded the same bytes.
http.ServeContent(w, r, "", d.CreatedAt, f)
}
// fail writes a plain-text response. Site errors are never JSON: whatever is on
// the other end of a static request is a browser or a curl, not an API client.
// http.Error sets the text/plain type and the nosniff header for us.
func (h *Handler) fail(w http.ResponseWriter, status int, msg string) {
http.Error(w, msg, status)
}
+377
View File
@@ -0,0 +1,377 @@
package site
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/store"
)
// depTime is the deployment's creation time, which is the Last-Modified of
// every file it serves.
var depTime = time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC)
type serveEnv struct {
h *Handler
cs *cas.Store
reg *Registry
p *Project
log *bytes.Buffer
}
// newServeEnv puts contents into a real content store and publishes a
// deployment of them. The store is real because the read path's whole shape —
// open by digest, hand the file to ServeContent — only means anything against
// actual files.
func newServeEnv(t *testing.T, cfg *ProjectConfig, contents map[string]string) *serveEnv {
t.Helper()
base := t.TempDir()
buf := &bytes.Buffer{}
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
cs, err := cas.Open(base+"/cas", cas.Options{ProbeDir: base + "/deployments", Log: log})
if err != nil {
t.Fatalf("cas.Open: %v", err)
}
t.Cleanup(func() { cs.Close() })
rows := make([]store.FileRow, 0, len(contents))
for p, c := range contents {
d := cas.Sum([]byte(c))
limit := int64(len(c))
if limit < 1 {
limit = 1
}
if _, err := cs.Put(t.Context(), d, int64(len(c)), limit, strings.NewReader(c)); err != nil {
t.Fatalf("cas.Put %s: %v", p, err)
}
rows = append(rows, store.FileRow{Path: p, Digest: d, Size: int64(len(c))})
}
sp := NewProject(&store.Project{ID: 1, Name: "demo"})
sp.SetConfig(cfg)
sp.Activate(NewDeployment(
&store.Deployment{PublicID: "dpl_0123456789abcdef", ProjectID: 1, CreatedAt: depTime},
NewIndex(rows), ""))
reg := NewRegistry()
reg.Replace([]*Project{sp})
return &serveEnv{
h: &Handler{Registry: reg, CAS: cs, Log: log},
cs: cs, reg: reg, p: sp, log: buf,
}
}
func (e *serveEnv) do(method, target string, header http.Header) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
for k, vs := range header {
req.Header[k] = vs
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
func (e *serveEnv) get(target string) *httptest.ResponseRecorder {
return e.do(http.MethodGet, target, nil)
}
func demoSite() map[string]string {
return map[string]string{
"index.html": "<h1>hello</h1>",
"assets/app.js": "console.log(1)",
"docs/index.html": "<p>docs</p>",
"404.html": "<p>gone</p>",
"data.bin": strings.Repeat("x", 4096),
"noext": "plain",
}
}
func TestServeFile(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.get("/~demo/assets/app.js")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Body.String(); got != "console.log(1)" {
t.Errorf("body = %q", got)
}
head := rec.Header()
if ct := head.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") &&
!strings.HasPrefix(ct, "application/javascript") {
t.Errorf("Content-Type = %q, want a javascript type", ct)
}
if want := `"sha256:` + cas.Sum([]byte("console.log(1)")).String() + `"`; head.Get("Etag") != want {
t.Errorf("ETag = %q, want %q", head.Get("Etag"), want)
}
if head.Get("X-Content-Type-Options") != "nosniff" {
t.Error("nosniff is missing")
}
if head.Get("Cache-Control") != plain().CacheControl {
t.Errorf("Cache-Control = %q", head.Get("Cache-Control"))
}
// The deployment's time, never the blob's: a shared blob's mtime says when
// some unrelated project first uploaded the same bytes.
if got := head.Get("Last-Modified"); got != depTime.Format(http.TimeFormat) {
t.Errorf("Last-Modified = %q, want %q", got, depTime.Format(http.TimeFormat))
}
if head.Get("Vary") != "" {
t.Errorf("Vary = %q with the SPA fallback off", head.Get("Vary"))
}
}
func TestServeIndexAndRedirects(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
if rec := e.get("/~demo/"); rec.Code != http.StatusOK || rec.Body.String() != "<h1>hello</h1>" {
t.Errorf("root: %d %q", rec.Code, rec.Body.String())
}
if rec := e.get("/~demo"); rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/~demo/" {
t.Errorf("bare name: %d %q", rec.Code, rec.Header().Get("Location"))
}
if rec := e.get("/~demo/docs"); rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/~demo/docs/" {
t.Errorf("directory: %d %q", rec.Code, rec.Header().Get("Location"))
}
// The query survives the redirect, or a link with parameters loses them.
if rec := e.get("/~demo/docs?a=1&b=2"); rec.Header().Get("Location") != "/~demo/docs/?a=1&b=2" {
t.Errorf("query dropped: %q", rec.Header().Get("Location"))
}
if rec := e.get("/~demo/docs/"); rec.Body.String() != "<p>docs</p>" {
t.Errorf("directory index: %q", rec.Body.String())
}
}
// A file name that needs escaping must not arrive raw in the Location header.
func TestServeRedirectEscapesTheLocation(t *testing.T) {
e := newServeEnv(t, plain(), map[string]string{"a b/index.html": "spaced"})
rec := e.do(http.MethodGet, "/~demo/a%20b", nil)
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want 301", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/~demo/a%20b/" {
t.Errorf("Location = %q, want the space escaped", got)
}
}
func TestServeMethodGate(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.do(http.MethodHead, "/~demo/index.html", nil)
if rec.Code != http.StatusOK {
t.Errorf("HEAD status = %d", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("HEAD returned %d bytes of body", rec.Body.Len())
}
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
rec := e.do(m, "/~demo/index.html", nil)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s status = %d, want 405", m, rec.Code)
}
if got := rec.Header().Get("Allow"); got != "GET, HEAD" {
t.Errorf("%s Allow = %q", m, got)
}
}
}
func TestServeUnknownProjectAndPath(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
for _, target := range []string{"/~nosuch/", "/~nosuch/index.html"} {
if rec := e.get(target); rec.Code != http.StatusNotFound {
t.Errorf("%s = %d, want 404", target, rec.Code)
}
}
for _, target := range []string{"/", "/index.html", "/api/v1/projects", "/~"} {
rec := e.get(target)
if rec.Code != http.StatusNotFound {
t.Errorf("%s = %d, want 404", target, rec.Code)
}
if !strings.Contains(rec.Body.String(), "/~project/") {
t.Errorf("%s: the 404 does not say where sites live: %q", target, rec.Body.String())
}
}
// A project with no deployment exists but has nothing to serve, which is a
// different answer from one that does not exist.
e.reg.Put(&store.Project{ID: 2, Name: "empty"})
if rec := e.get("/~empty/"); rec.Code != http.StatusServiceUnavailable {
t.Errorf("undeployed project = %d, want 503", rec.Code)
}
}
func TestServeConditionalRequest(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
etag := e.get("/~demo/index.html").Header().Get("Etag")
if etag == "" {
t.Fatal("no ETag to revalidate with")
}
rec := e.do(http.MethodGet, "/~demo/index.html", http.Header{"If-None-Match": {etag}})
if rec.Code != http.StatusNotModified {
t.Errorf("status = %d, want 304", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("304 carried %d bytes", rec.Body.Len())
}
// A stale validator must still transfer the content.
rec = e.do(http.MethodGet, "/~demo/index.html", http.Header{"If-None-Match": {`"sha256:stale"`}})
if rec.Code != http.StatusOK || rec.Body.String() != "<h1>hello</h1>" {
t.Errorf("stale validator: %d %q", rec.Code, rec.Body.String())
}
rec = e.do(http.MethodGet, "/~demo/index.html",
http.Header{"If-Modified-Since": {depTime.Add(time.Hour).Format(http.TimeFormat)}})
if rec.Code != http.StatusNotModified {
t.Errorf("If-Modified-Since: status = %d, want 304", rec.Code)
}
}
func TestServeRange(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.do(http.MethodGet, "/~demo/data.bin", http.Header{"Range": {"bytes=10-19"}})
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if got := rec.Body.String(); got != strings.Repeat("x", 10) {
t.Errorf("body = %q (%d bytes)", got, len(got))
}
if got := rec.Header().Get("Content-Range"); got != "bytes 10-19/4096" {
t.Errorf("Content-Range = %q", got)
}
if rec.Header().Get("Accept-Ranges") != "bytes" {
t.Error("ranges are not advertised")
}
}
func TestServeCustom404(t *testing.T) {
e := newServeEnv(t, withNotFound(), demoSite())
rec := e.get("/~demo/nope.txt")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if rec.Body.String() != "<p>gone</p>" {
t.Errorf("body = %q, want the project's error document", rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
t.Errorf("Content-Type = %q", got)
}
if got := rec.Header().Get("Content-Length"); got != "11" {
t.Errorf("Content-Length = %q, want 11", got)
}
// An error document is not a cacheable validator target; it must not claim
// to be the requested resource.
if rec.Header().Get("Etag") != "" {
t.Error("the error document was served with an ETag")
}
// HEAD gets the headers and no body.
rec = e.do(http.MethodHead, "/~demo/nope.txt", nil)
if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 {
t.Errorf("HEAD of a 404: %d, %d bytes", rec.Code, rec.Body.Len())
}
}
func TestServeSPAVariesOnAccept(t *testing.T) {
e := newServeEnv(t, withSPA(), demoSite())
rec := e.do(http.MethodGet, "/~demo/client/route", http.Header{"Accept": {"text/html"}})
if rec.Code != http.StatusOK || rec.Body.String() != "<h1>hello</h1>" {
t.Fatalf("navigation: %d %q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Vary") != "Accept" {
t.Error("a response that depends on Accept did not say so")
}
rec = e.do(http.MethodGet, "/~demo/client/route", http.Header{"Accept": {"*/*"}})
if rec.Code != http.StatusNotFound {
t.Errorf("fetch: %d, want 404", rec.Code)
}
}
func TestServeUnknownExtensionIsNotSniffed(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.get("/~demo/noext")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" {
t.Errorf("Content-Type = %q, want application/octet-stream", got)
}
}
func TestServeBadRequestPath(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.do(http.MethodGet, "/~demo/bad%00path", nil)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
// The grace period is supposed to make this unreachable, so it means a bug or
// an operator who cleared the store by hand. The request must fail cleanly and
// the reason must reach the log.
func TestServeMissingBlobIs404AndLogged(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
if err := os.Remove(e.cs.Path(cas.Sum([]byte("<h1>hello</h1>")))); err != nil {
t.Fatal(err)
}
rec := e.get("/~demo/index.html")
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
if !strings.Contains(e.log.String(), "blob missing from the content store") {
t.Errorf("nothing was logged about the missing blob: %s", e.log.String())
}
}
// An in-flight response keeps reading the deployment it started on. This is the
// property a symlink swap cannot give, and the reason the switch is a pointer
// store.
func TestServeInFlightResponseSurvivesAnActivation(t *testing.T) {
e := newServeEnv(t, plain(), map[string]string{"index.html": "v1"})
// Open the blob the way the handler does, then activate over the top of it.
first := e.p.Active()
entry, ok := first.Lookup("index.html")
if !ok {
t.Fatal("no index.html in the first deployment")
}
f, err := e.cs.Open(entry.Digest)
if err != nil {
t.Fatal(err)
}
defer f.Close()
d2 := cas.Sum([]byte("v2"))
if _, err := e.cs.Put(t.Context(), d2, 2, 2, strings.NewReader("v2")); err != nil {
t.Fatal(err)
}
e.p.Activate(NewDeployment(
&store.Deployment{PublicID: "dpl_second", ProjectID: 1, CreatedAt: depTime},
NewIndex([]store.FileRow{{Path: "index.html", Digest: d2, Size: 2}}), ""))
buf := make([]byte, 8)
n, _ := f.Read(buf)
if string(buf[:n]) != "v1" {
t.Errorf("the open file returned %q, want the deployment it was opened on", buf[:n])
}
if rec := e.get("/~demo/"); rec.Body.String() != "v2" {
t.Errorf("a new request got %q, want v2", rec.Body.String())
}
}
@@ -0,0 +1,3 @@
go test fuzz v1
string("0/.")
string("0")