Files
bdeshiandClaude Opus 5 9349c54d2e let a feature own a route, and serve the site's own files at exact paths
Addresses like /.well-known/security.txt are fixed by somebody else's spec.
None is a bundle, none belongs under /static/, and core had no way to serve one.

This is the trigger the extension registry has been held for, in those words:
ADR-0042 called core's generic derived-file route "the seam to revisit when a
second feature wants output of its own", and state.md's counter note said to
build the registry "when a feature wants a route". Raw passthrough is that
feature, so the seam is built rather than worked around.

Only Routes, not the seven-field Extension struct extensions.md describes. Five
of the other six fields have no implementor and building them would be the
speculation rule 6 forbids. It also kept the change inside the core budget,
which had 65 lines left: the seam is ~30 core lines and the feature's own code
lands in internal/ext/, where there is room. Core is 2965/3000.

A feature returns map[string]http.Handler; core mounts each as an exact pattern
and learns nothing about who owns it. A path core already answers is skipped
with a warning, not overridden — http.ServeMux panics on a duplicate pattern, so
a site shipping root/robots.txt would otherwise take the server down at startup.
Verified: server alive, engine keeps /robots.txt, warning logged, zero panics.

Templating is opt-in by filename. A .tmpl suffix is stripped from the URL and
the file is rendered with text/template — never html/template, which would turn
an ampersand in a contact address into & and a JSON quote into ". Opt-in
by name rather than by sniffing the type, because a key or a signature may
contain anything and a pass choosing for itself which files to rewrite would
eventually eat one. The data is the site's own declarations and nothing more,
which is the point: a security.txt naming its canonical URL should not repeat
what site.yaml already says.

Headers come from root/_headers.yaml, exact paths only. Globs are a second-use
feature and the concrete need is a handful of .well-known names. The manifest is
not served, by the leading-underscore rule that already means "not addressable"
everywhere else — no special case was added for it. A manifest that will not
parse is logged and ignored; the files still serve.

Found while counting: the Extensions row read 4 while five packages existed.
notation landed in ADR-0061/0062 and was never counted, though the prose beside
the number already named all five. Corrected to 6. That is the latent item about
counters having no mechanical check, demonstrating itself.

Not done, and logged as latent: khosra check cannot report a root/ file
shadowing an engine path, because verify.sh fails a feature that imports a
sibling and the reserved paths live in passthrough. The startup warning fires on
every boot, which is louder than a check finding.

Evidence against the demo with a fresh binary: /pubkey answers with its declared
text/plain despite having no extension; /.well-known/security.txt answers with
Canonical filled from site.yaml's base, plus the declared CORS header;
/humans.txt gets a derived type; /_headers.yaml is 404; / and a bundle page are
untouched. Eight unit tests cover layout, absence, interpolation, non-escaping,
declared and derived headers, a broken template, and a broken manifest.

24 files, +514/-46. Extensions 4 (miscounted) → 6. Routing cases unmoved: exact
paths are mux entries, not resolver cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:48:59 +06:00

156 lines
5.8 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
"khosra/internal/render"
)
func extrasFS() fstest.MapFS {
return fstest.MapFS{
"content/writing/story/index.md": {Data: []byte("---\ntitle: A Story\ndate: 2026-01-01\n---\nThe story itself.\n")},
"content/writing/story/extras/notes.md": {Data: []byte("## Notes\n\nWith *emphasis*.\n")},
"content/writing/story/extras/log.txt": {Data: []byte("day one: <not markup>\n")},
"content/writing/story/extras/scan.jpg": {Data: []byte("jpeg bytes")},
"content/writing/story/extras/drafts/v1.md": {Data: []byte("first attempt\n")},
"content/writing/plain.md": {Data: []byte("---\ntitle: Plain\ndate: 2026-01-02\n---\nx\n")},
}
}
func extrasHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
t.Helper()
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
}
func TestExtrasAreNotBundles(t *testing.T) {
// The scanner skips the directory entirely, so a .md in there has no URL, no identity and never appears in
// a listing (content-model.md).
fsys := extrasFS()
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
for _, b := range bundles {
if strings.Contains(b.Key, "extras") {
t.Errorf("%q was scanned as a bundle", b.Key)
}
}
if len(bundles) != 2 {
t.Errorf("scanned %d bundles, want 2", len(bundles))
}
h := extrasHandler(t, fsys)
for _, path := range []string{"/writing/story/extras/notes/", "/writing/story/extras/drafts/v1/"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "<h1>") {
t.Errorf("GET %s rendered a page for something that is not a bundle", path)
}
}
}
func TestTheExtrasListingShowsTheTreeClassified(t *testing.T) {
h := extrasHandler(t, extrasFS())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"notes.md", "log.txt", "scan.jpg", "drafts", "markdown", "text", "image"} {
if !strings.Contains(body, want) {
t.Errorf("listing missing %q:\n%s", want, body)
}
}
// Sorted by path, so the order is the same on every request and a numeric prefix orders a set.
if strings.Index(body, "drafts") > strings.Index(body, "log.txt") {
t.Errorf("entries should be sorted by path:\n%s", body)
}
// A bundle with no extras has no listing at all.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/plain/extras/", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("a bundle without extras = %d, want 404", rec.Code)
}
}
func TestSelectingAnEntryRendersWhatItCanAndOffersTheRest(t *testing.T) {
h := extrasHandler(t, extrasFS())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/notes.md", nil))
body := rec.Body.String()
if !strings.Contains(body, `<h2 id="notes">Notes</h2>`) || !strings.Contains(body, "<em>emphasis</em>") {
t.Errorf("markdown should be rendered:\n%s", body)
}
// The tree is still there: selecting is a link, and the page is a full re-render, so no JavaScript is needed.
if !strings.Contains(body, "log.txt") {
t.Errorf("the listing should still be shown beside the selection:\n%s", body)
}
// A text file is shown as text, escaped — a log is not markup.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/log.txt", nil))
body = rec.Body.String()
if !strings.Contains(body, "&lt;not markup&gt;") {
t.Errorf("a text entry must be escaped, not interpreted:\n%s", body)
}
// A picture cannot be rendered inline as text, so it arrives with a raw URL instead.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/scan.jpg", nil))
if body := rec.Body.String(); !strings.Contains(body, "scan.jpg?raw") {
t.Errorf("an image entry should offer its bytes:\n%s", body)
}
}
func TestRawReturnsTheBytesAndNothingElse(t *testing.T) {
h := extrasHandler(t, extrasFS())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/scan.jpg?raw", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if got := rec.Body.String(); got != "jpeg bytes" {
t.Errorf("body = %q, want the file itself", got)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
t.Errorf("content-type = %q", ct)
}
}
func TestExtrasCannotBeUsedToWanderOrToReadAnUnpublishedBundle(t *testing.T) {
fsys := extrasFS()
fsys["content/writing/secret/index.md"] = &fstest.MapFile{Data: []byte("---\ntitle: Secret\ndraft: true\n---\nx\n")}
fsys["content/writing/secret/extras/plan.md"] = &fstest.MapFile{Data: []byte("the plan\n")}
h := extrasHandler(t, fsys)
for _, path := range []string{
"/writing/story/extras/../../../etc/passwd",
"/writing/story/extras/nothing.md",
"/writing/story/extras/drafts", // a directory is not an entry to show
"/writing/secret/extras/", // draft: the whole bundle is hidden (ADR-0024)
"/writing/secret/extras/plan.md",
"/writing/secret/extras/plan.md?raw",
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusOK {
t.Errorf("GET %s = 200, want a miss:\n%s", path, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "the plan") {
t.Fatalf("GET %s leaked an unpublished bundle's extras", path)
}
}
}