All checks were successful
check / check (push) Successful in 3m47s
GORM's default logger printed the fully interpolated SQL to standard
output on every statement that returned an error, including a plain
record-not-found. On /webhook/{uuid} and on the login form the
interpolated parameter is client-chosen and unbounded, so an
unauthenticated client sized the operator's log, one line per request,
at no level the operator could turn down.
Every gorm.Open in the service now installs internal/gormlog, a
gormlogger.Interface over the service's *slog.Logger. Its lines take
the level the operator set and the handler internal/logger selected; a
record-not-found is not logged as an error, since it is the expected
outcome on both of those paths and each handler already records its
own miss at DEBUG without the SQL; slow statements are kept at WARN
above the same 200ms threshold GORM used; and every value it emits is
spent through internal/logfield, the same encoded-byte budget the
access log spends. MaxAccessLogLineBytes bounds a GORM line too, and
internal/gormlog asserts each line against the constant directly.
Trace orders its cases exactly as GORM's own Trace orders them --
error-that-is-not-a-miss, then slow, then routine -- so a statement
that both missed and ran slow is still reported as slow. Ordering the
drop first would have made this adapter strictly less observant than
the IgnoreRecordNotFoundError option it was chosen over, on the two
lookups the issue is about, and a miss is the statement most likely to
be slow.
The third gorm.Open, in the archive writer, was not named in the issue
and had the same default. All three sites are pinned independently:
internal/handlers covers the main and per-webhook databases,
internal/delivery covers the archive writer, whose type is unexported.
Reverting any one of the three to a bare &gorm.Config{} fails the
suite.
The flood test's per-line and volume assertions were vacuous, because
the replaced default logger wrote only to a buffer while everything
else went to the captured stdout. It now tees to stdout as GORM's real
default does, so a reverted call site lands in the same capture and
those assertions measure the whole writer set.
internal/logfield gains the zero-headroom budget assertion and the
rune-splitting case: a value built from one rune must keep exactly
MaxBytes/EncodedBytes(r) of them, which a raw-byte budget fails and a
LessOrEqual on the budget cannot catch.
README: the ceiling now covers GORM, and the writers it does not cover
are re-derived by measuring rather than by reading. fx's console
logger and the Go runtime write to standard error. net/http's nil
ErrorLog is not a separate writer at all -- slog.SetDefault redirects
the log package's default logger into internal/logger's handler, so
those lines arrive on standard output at INFO. A handler panic reaches
that same path because chi's Recoverer crashes before writing, filed
as #187, and is the widest
line the service can write: measured at roughly 2,770 bytes against
the stated 2,560, a width that moves with the goroutine number and the
source paths in the stack, so only the fact that it exceeds the
ceiling is stated as invariant.
322 lines
8.6 KiB
Go
322 lines
8.6 KiB
Go
package logfield_test
|
||
|
||
import (
|
||
"bytes"
|
||
"io"
|
||
"log/slog"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf8"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
"sneak.berlin/go/webhooker/internal/logfield"
|
||
)
|
||
|
||
// budget is the field budget these tests spend. Small enough that a
|
||
// cut is unambiguous, large enough to hold several runes of every
|
||
// width.
|
||
const budget = 64
|
||
|
||
// sampleRunes is how many runes wide the values in the charge test
|
||
// are. The handlers add a constant per field — a pair of quotes when
|
||
// the value needs quoting — so the per-rune charge is only visible
|
||
// once it is amortised over a run of them.
|
||
const sampleRunes = 64
|
||
|
||
// quotingSlack is that constant: the pair of quotes a handler adds to
|
||
// a value that needs them and omits from one that does not.
|
||
const quotingSlack = 2
|
||
|
||
// newHandlers are the two handlers internal/logger can install. Time
|
||
// is dropped so a line's width is a function of its value alone —
|
||
// RFC3339Nano trims trailing zeros, so two consecutive timestamps do
|
||
// not render to the same number of bytes.
|
||
func newHandlers() map[string]func(io.Writer) slog.Handler {
|
||
opts := &slog.HandlerOptions{
|
||
Level: slog.LevelDebug,
|
||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||
if a.Key == slog.TimeKey {
|
||
return slog.Attr{}
|
||
}
|
||
|
||
return a
|
||
},
|
||
}
|
||
|
||
return map[string]func(io.Writer) slog.Handler{
|
||
"json": func(w io.Writer) slog.Handler {
|
||
return slog.NewJSONHandler(w, opts)
|
||
},
|
||
"text": func(w io.Writer) slog.Handler {
|
||
return slog.NewTextHandler(w, opts)
|
||
},
|
||
}
|
||
}
|
||
|
||
// renderedWidth is the number of bytes a handler writes for a line
|
||
// carrying value in a single attribute.
|
||
func renderedWidth(
|
||
newHandler func(io.Writer) slog.Handler,
|
||
value string,
|
||
) int {
|
||
buf := new(bytes.Buffer)
|
||
slog.New(newHandler(buf)).Info("m", "v", value)
|
||
|
||
return buf.Len()
|
||
}
|
||
|
||
// chargeTestRunes is the set of code points the charge test measures:
|
||
// every rune in the first two planes' worth of the BMP that the
|
||
// handlers are most likely to treat specially, the separators that
|
||
// only slog's JSON handler escapes, and a stratified sample across
|
||
// the rest of Unicode so the astral charge is exercised on more than
|
||
// one hand-picked rune.
|
||
func chargeTestRunes() []rune {
|
||
const (
|
||
denseCeiling = 0x800
|
||
stride = 1021
|
||
surrogateLo = 0xD800
|
||
surrogateHi = 0xDFFF
|
||
)
|
||
|
||
var runes []rune
|
||
|
||
keep := func(r rune) {
|
||
if r >= surrogateLo && r <= surrogateHi {
|
||
return
|
||
}
|
||
|
||
runes = append(runes, r)
|
||
}
|
||
|
||
for r := range rune(denseCeiling) {
|
||
keep(r)
|
||
}
|
||
|
||
for _, r := range []rune{
|
||
0x2028, 0x2029, 0x200B, 0x4E00, 0xE000, 0xFFFD,
|
||
0x1000C, 0x1F600, 0xE0001, 0x10FFFF,
|
||
} {
|
||
keep(r)
|
||
}
|
||
|
||
for r := rune(denseCeiling); r <= utf8.MaxRune; r += stride {
|
||
keep(r)
|
||
}
|
||
|
||
return runes
|
||
}
|
||
|
||
// TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit is the property
|
||
// the whole capping scheme rests on: a rune may not cost more on the
|
||
// line than the budget was charged for it. An undercharged rune is
|
||
// how a stated ceiling becomes false without any test noticing, so
|
||
// the charge is measured against what the handlers actually write
|
||
// rather than against the escaping rules as read.
|
||
func TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for name, newHandler := range newHandlers() {
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// 'a' is a printable ASCII rune, charged exactly one
|
||
// byte, so it is the zero point the other runes are
|
||
// measured against.
|
||
base := renderedWidth(
|
||
newHandler, strings.Repeat("a", sampleRunes),
|
||
)
|
||
|
||
for _, r := range chargeTestRunes() {
|
||
got := renderedWidth(
|
||
newHandler,
|
||
strings.Repeat(string(r), sampleRunes),
|
||
)
|
||
charged := sampleRunes *
|
||
(logfield.EncodedBytes(r) - 1)
|
||
|
||
require.LessOrEqual(
|
||
t, got-base, charged+quotingSlack,
|
||
"U+%04X costs more on the line than "+
|
||
"EncodedBytes charges for it",
|
||
r,
|
||
)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestTruncate_SpendsNoMoreThanTheBudget holds the result to the
|
||
// budget in ENCODED bytes, which is the unit the budget is stated in.
|
||
// A raw-byte cap passes the ASCII case here and fails every other
|
||
// one.
|
||
func TestTruncate_SpendsNoMoreThanTheBudget(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for name, fill := range map[string]string{
|
||
"plain": "x",
|
||
"quote": `"`,
|
||
"backslash": `\`,
|
||
"tab": "\t",
|
||
"newline": "\n",
|
||
"control": "\x01",
|
||
"astral": "\U0001000C",
|
||
// U+4E00, a printable multi-byte rune, charged its three
|
||
// UTF-8 bytes rather than an escape. Spelled numerically
|
||
// because gosmopolitan rejects Han in a string literal.
|
||
"cjk": string(rune(0x4E00)),
|
||
} {
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := logfield.Truncate(
|
||
strings.Repeat(fill, budget*8), budget,
|
||
)
|
||
|
||
require.True(
|
||
t, strings.HasSuffix(
|
||
got, logfield.TruncationMarker,
|
||
),
|
||
"an oversized value must be marked as cut",
|
||
)
|
||
|
||
spent := 0
|
||
for _, r := range strings.TrimSuffix(
|
||
got, logfield.TruncationMarker,
|
||
) {
|
||
spent += logfield.EncodedBytes(r)
|
||
}
|
||
|
||
assert.LessOrEqual(t, spent, budget)
|
||
assert.True(t, utf8.ValidString(got))
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestTruncate_LeavesShortValuesAlone keeps the marker meaningful: a
|
||
// value that fits comes back byte for byte, so a marked value is
|
||
// always a cut one.
|
||
func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, s := range []string{
|
||
"", "GET", "/source/abc/edit", "Mozilla/5.0 (X11)",
|
||
} {
|
||
assert.Equal(t, s, logfield.Truncate(s, budget))
|
||
}
|
||
}
|
||
|
||
// TestTruncate_DropsInvalidUTF8 covers the bytes a header can carry
|
||
// that were never valid UTF-8. Keeping them would make the encoder
|
||
// spend six bytes apiece replacing them, which is exactly the
|
||
// amplification the budget exists to prevent.
|
||
func TestTruncate_DropsInvalidUTF8(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := logfield.Truncate("a\xffb\xfe\xfec", budget)
|
||
|
||
assert.Equal(t, "abc", got)
|
||
assert.True(t, utf8.ValidString(got))
|
||
}
|
||
|
||
// encodedCost is what a whole string costs on a line, by the same
|
||
// accounting Truncate spends its budget with.
|
||
func encodedCost(s string) int {
|
||
total := 0
|
||
for _, r := range s {
|
||
total += logfield.EncodedBytes(r)
|
||
}
|
||
|
||
return total
|
||
}
|
||
|
||
// TestTruncate_SpendsEncodedBytesNotRawBytes is the zero-headroom
|
||
// version of TestTruncate_SpendsNoMoreThanTheBudget above, and of the
|
||
// line-length assertions elsewhere.
|
||
//
|
||
// A line ceiling has slack in it by construction, and a LessOrEqual
|
||
// against the budget cannot tell a budget spent exactly from one
|
||
// spent under. Here the budget is checked against exactly what it
|
||
// bought: a value built from a single rune must keep exactly
|
||
// MaxBytes/EncodedBytes(r) of them, with nothing spare. A raw-byte
|
||
// budget — cost := utf8.RuneLen(r) — fails this for every rune the
|
||
// handlers escape.
|
||
func TestTruncate_SpendsEncodedBytesNotRawBytes(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for name, r := range map[string]rune{
|
||
"plain": 'x',
|
||
"quote": '"',
|
||
"backslash": '\\',
|
||
"tab": '\t',
|
||
"newline": '\n',
|
||
"carriage_return": '\r',
|
||
"c0_control": '\x01',
|
||
"del": '\x7f',
|
||
// U+2028 LINE SEPARATOR, which only the JSON handler
|
||
// escapes.
|
||
"line_separator": '
',
|
||
"astral_nonprintable": '\U0001000C',
|
||
"multibyte_printable": 'é',
|
||
// U+20AC, a three-byte printable rune, charged its own
|
||
// UTF-8 bytes rather than an escape.
|
||
"three_byte_printable": '€',
|
||
"emoji_printable": '\U0001F600',
|
||
} {
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cost := logfield.EncodedBytes(r)
|
||
want := logfield.MaxBytes / cost
|
||
|
||
// Far past the budget under either accounting.
|
||
in := strings.Repeat(string(r), logfield.MaxBytes*2)
|
||
|
||
got := logfield.Truncate(in, logfield.MaxBytes)
|
||
|
||
require.True(
|
||
t, strings.HasSuffix(
|
||
got, logfield.TruncationMarker,
|
||
),
|
||
"a value past the budget must be marked",
|
||
)
|
||
|
||
kept := strings.TrimSuffix(
|
||
got, logfield.TruncationMarker,
|
||
)
|
||
|
||
assert.Equal(
|
||
t, want, utf8.RuneCountInString(kept),
|
||
"budget bought the wrong number of runes at "+
|
||
"%d encoded bytes each", cost,
|
||
)
|
||
assert.LessOrEqual(
|
||
t, encodedCost(kept), logfield.MaxBytes,
|
||
)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestTruncate_NeverSplitsARune covers a cut landing inside a
|
||
// multi-byte encoding rather than between two of them.
|
||
func TestTruncate_NeverSplitsARune(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// U+20AC, three bytes and printable, so a small budget lands
|
||
// inside an encoding rather than on a boundary.
|
||
in := strings.Repeat("€", logfield.MaxBytes)
|
||
|
||
for b := 1; b <= 16; b++ {
|
||
got := strings.TrimSuffix(
|
||
logfield.Truncate(in, b), logfield.TruncationMarker,
|
||
)
|
||
|
||
assert.True(
|
||
t, utf8.ValidString(got),
|
||
"budget %d produced invalid UTF-8", b,
|
||
)
|
||
assert.LessOrEqual(t, encodedCost(got), b)
|
||
}
|
||
}
|