rastrillo / fiesta Public

Read _headers values as CARLOS does: quotes keep commas, ASCII only

The test server's _headers parser claimed to accept and refuse the
files CARLOS does, and did not in two ways. It split a Cache-Control
value at every comma, so it refused no-cache="X-One, X-Two", which
the edge reads as one directive. And it never held a value to
visible ASCII, so it accepted a quoted non-ASCII value, and a tab
beside a comma, both of which the edge refuses. Neither changes the
file fiesta site writes today, but a test server that disagrees with
the edge passes a file carlos ship would refuse, or fails one it
would take.

The header line is now read by parseHeaderLine, CARLOS's own steps
in its order, with the visible-ASCII check before the value's check,
and Cache-Control is split by splitOutsideQuotes, as the edge splits
it (internal/edge/headers.go at v0.31.1).

Red: the three new cases in
TestServeSiteRefusesAHeadersFileCarlosWouldRefuse failed by
assertion against the old parser: the quoted list was refused, the
non-ASCII value and the tab were accepted. CARLOS's own parseHeaders,
run on the same three strings, accepts the first and refuses the
other two.
Human

Prompt

Minor: the test server's _headers parser differs from CARLOS (internal/www/headers_test.go:152). Mirror CARLOS's quote-aware splitting and its visible-ASCII validation. The parser is internal/edge in /home/paulca/.cache/tmp/claude-1001/design-managed-fleets-457, at v0.31.1's edge code; read it, don't edit it. Add regression cases for no-cache="X-One, X-Two", which must be accepted, and a quoted non-ASCII value, which must be refused.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

The test server's _headers parser splits Cache-Control outside quotes and holds values to visible ASCII, as the edge does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 66f8e5764f9115b2584d2a89a919edc3cfa93f81 parent 03bee3b
1 file changed, +72 −14
  • internal/www/headers_test.go +72 −14
diff --git a/internal/www/headers_test.go b/internal/www/headers_test.go
index 6c49d8e..ff08cb8 100644
--- a/internal/www/headers_test.go
+++ b/internal/www/headers_test.go
@@ -116,24 +116,53 @@ func parseHeaders(data []byte) ([]headerRule, error) {
if cur == nil {
continue
}
- name, value, ok := strings.Cut(trimmed, ":")
- name, value = http.CanonicalHeaderKey(strings.Trim(name, " \t")), strings.Trim(value, " \t")
- check, known := headerChecks[name]
- switch {
- case !ok || name == "" || strings.HasPrefix(name, "!"):
- bad(n, fmt.Errorf("%q: want a header line like \"Name: value\"", trimmed))
- case !known:
- bad(n, fmt.Errorf("header %q is not supported", name))
- case value == "" || !check(value):
- bad(n, fmt.Errorf("%s: the edge refuses %q", name, value))
- default:
- cur.set.Set(name, value)
+ name, value, err := parseHeaderLine(trimmed)
+ if err != nil {
+ bad(n, err)
+ continue
}
+ cur.set.Set(name, value)
}
finish()
return rules, errors.Join(errs...)
}
+// parseHeaderLine splits and checks one "Name: value" line as the edge
+// does, returning the canonical name and the trimmed value. The value
+// is held to visible ASCII and spaces before its header's check reads
+// it, as the edge holds it: a quoted string passes the Cache-Control
+// grammar whatever it holds, and a tab beside a comma is trimmed away
+// with the spaces, so without this a value the edge refuses would
+// parse here.
+func parseHeaderLine(line string) (name, value string, err error) {
+ if strings.HasPrefix(line, "!") {
+ return "", "", fmt.Errorf("%q: removing a header with ! is not supported", line)
+ }
+ n, v, ok := strings.Cut(line, ":")
+ n = strings.Trim(n, " \t")
+ if !ok || n == "" {
+ return "", "", fmt.Errorf("%q: want a header line like \"Name: value\"", line)
+ }
+ name = http.CanonicalHeaderKey(n)
+ check, ok := headerChecks[name]
+ if !ok {
+ return "", "", fmt.Errorf("header %q is not supported", name)
+ }
+ value = strings.Trim(v, " \t")
+ if value == "" {
+ return "", "", fmt.Errorf("%s: the value is empty", name)
+ }
+ for i := 0; i < len(value); i++ {
+ if value[i] < ' ' || value[i] > '~' {
+ return "", "", fmt.Errorf("%s: the value may hold only visible ASCII and spaces", name)
+ }
+ }
+ if !check(value) {
+ return "", "", fmt.Errorf("%s: the edge refuses %q", name, value)
+ }
+ return name, value, nil
+}
+
// isToken reports whether s is an HTTP token (RFC 9110).
func isToken(s string) bool {
if s == "" {
@@ -148,10 +177,32 @@ func isToken(s string) bool {
return true
}
+// splitOutsideQuotes splits v at the commas outside a quoted string,
+// as the edge does, so no-cache="X-One, X-Two" stays one directive.
+// An unterminated quote leaves the rest of v in one piece, which
+// cacheDirectives then refuses.
+func splitOutsideQuotes(v string) []string {
+ var parts []string
+ start, quoted := 0, false
+ for i := 0; i < len(v); i++ {
+ switch v[i] {
+ case '"':
+ quoted = !quoted
+ case ',':
+ if !quoted {
+ parts = append(parts, v[start:i])
+ start = i + 1
+ }
+ }
+ }
+ return append(parts, v[start:])
+}
+
// cacheDirectives: a comma-separated list of directives, each a token
-// or token=value, the value a token or a quoted string.
+// or token=value, the value a token or a quoted string, split only at
+// commas outside quotes.
func cacheDirectives(v string) bool {
- for _, d := range strings.Split(v, ",") {
+ for _, d := range splitOutsideQuotes(v) {
name, val, hasVal := strings.Cut(strings.Trim(d, " \t"), "=")
quoted := len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' && !strings.ContainsAny(val[1:len(val)-1], `"\`)
if !isToken(name) || hasVal && !isToken(val) && !quoted {
@@ -537,6 +588,11 @@ func TestServeSiteRefusesAHeadersFileCarlosWouldRefuse(t *testing.T) {
"/a/*/b\n Cache-Control: no-cache\n",
"/a\n Cache-Control: " + strings.Repeat("x", headersMaxLine) + "\n",
many.String(),
+ // Visible ASCII only, checked before the directives are read:
+ // a quoted string passes the directive grammar whatever it
+ // holds, and a tab is trimmed away around a directive.
+ "/a\n Cache-Control: private=\"Ünïcode\"\n",
+ "/a\n Cache-Control: public,\tmax-age=1\n",
} {
if _, err := parseHeaders([]byte(file)); err == nil {
t.Errorf("%.60q parsed; CARLOS refuses it", file)
@@ -547,6 +603,8 @@ func TestServeSiteRefusesAHeadersFileCarlosWouldRefuse(t *testing.T) {
"/a\n cache-control: no-cache\n",
"/a/:x/*\n\tAccess-Control-Expose-Headers: ETag, Content-Length\n",
"https://fiesta.rastrillo.org/a\n Cache-Control: max-age=1, stale-if-error=\"60\"\n",
+ // A comma inside a quoted string does not end the directive.
+ "/a\n Cache-Control: no-cache=\"X-One, X-Two\"\n",
} {
if _, err := parseHeaders([]byte(file)); err != nil {
t.Errorf("%q was refused (%v); CARLOS accepts it", file, err)