From 93ecef86a42e32085265ab395b442f1d79cd3e90 Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Wed, 16 Sep 2026 09:39:11 -0400 Subject: [PATCH] caddyfile: detect end-of-input-swallowing tokens from token shape hasUnformattableToken documented an unterminated quote/backtick as a shape that cannot be rendered idempotently, but never implemented the check. The condition was only caught dynamically by trailingNewlineChangesTokens, which returns early for input that already ends in a newline. That early return is wrong for a token that swallowed the newline: once Format's mandatory trailing newline sits inside an unterminated quote, appending another one no longer changes the stream, so the second pass rendered what the first pass had preserved verbatim. Format(`"""`) => `"""` (fallback, preserved) Format(`"""` + newline) => `"" "` (rendered, space inserted) Decide it from the token instead: a non-quoted token whose verbatim source ends in a newline swallowed it, since a newline otherwise terminates a token. This also covers an escaped quote ("\"") running to end-of-input, which has the same shape. A lone "\r" is kept in raw but does not terminate a token, so it is deliberately excluded; heredoc tokens carry wasQuoted == '<'. Master's rune-based formatter was idempotent on these inputs, so this restores parity. Found by FuzzFormatIdempotent; the inputs are added as portable seeds and table cases rather than testdata/fuzz files. Co-Authored-By: Claude Opus 5 (1M context) --- caddyconfig/caddyfile/formatter.go | 15 +++++++++++ caddyconfig/caddyfile/formatter_test.go | 36 ++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/caddyconfig/caddyfile/formatter.go b/caddyconfig/caddyfile/formatter.go index 487bbfb5a..eed0622a0 100644 --- a/caddyconfig/caddyfile/formatter.go +++ b/caddyconfig/caddyfile/formatter.go @@ -378,6 +378,21 @@ func hasUnformattableToken(tokens []Token) bool { if tk.wasQuoted == 0 && endsInDanglingBackslash(raw) { return true } + // A non-quoted token whose verbatim source ends in a newline swallowed it: + // an unterminated quote/backtick, or an escaped quote ("\\"") that ran to + // end-of-input. A newline otherwise terminates a token, so no well-formed + // token ends in one. Format emits the source verbatim and then trims + // trailing whitespace, so such a token changes on re-lex and no rendering + // is a fixed point. This has to be decided from the token itself rather + // than from whether appending a newline changes the stream: once the + // mandatory trailing newline has been appended it sits inside the + // swallowing token, and appending another one no longer changes anything, + // so only the token shape still reveals the problem. A lone "\\r" is kept + // in raw but does not terminate a token, so it is deliberately not + // treated as swallowed; heredoc tokens carry wasQuoted == '<'. + if tk.wasQuoted == 0 && strings.HasSuffix(raw, "\n") { + return true + } if tk.wasQuoted != 0 { continue } diff --git a/caddyconfig/caddyfile/formatter_test.go b/caddyconfig/caddyfile/formatter_test.go index b401e9042..13fc85774 100644 --- a/caddyconfig/caddyfile/formatter_test.go +++ b/caddyconfig/caddyfile/formatter_test.go @@ -804,7 +804,11 @@ func hasHeredocOpenerShapedToken(in []byte) bool { } func FuzzFormatIdempotent(f *testing.F) { - for _, s := range []string{"", " ", "a{\nb\n}", "site {\n\tfoo # c\n}\n", "x <