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>
1 file changed,
+72
−14
internal/www/headers_test.go+72 −14
Intent
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) |