// Package logfield bounds a client-supplied value against what the log // handler will actually emit for it, so a line's size is set by this // service rather than by the client that provoked it. // // It lives outside internal/middleware because more than one writer // needs it: the access log, and the GORM adapter in internal/gormlog, // which logs SQL with the client-chosen parameters interpolated into // it. One budget, one implementation. package logfield import ( "strings" "unicode" "unicode/utf8" ) const ( // MaxBytes is the default budget for a field whose value the // client supplies outright. It is spent in ENCODED bytes (see // Truncate), so 512 still holds a real browser's User-Agent whole // — those are plain ASCII, which encodes one byte for one — while // a value built from characters the encoder escapes keeps a // shorter prefix. That is the intended trade: 500 quotation marks // are not a debugging asset. MaxBytes = 512 // truncationMarker is appended to any value Truncate cut, so a // short value and a truncated one cannot be confused. It is // charged on top of the budget, not inside it. truncationMarker = "[truncated]" ) // EncodedBytes is what r costs on the line once the log handler has // escaped it, taking the worse of the two handlers internal/logger // configures. // // slog's JSON handler escapes quote, backslash, newline, carriage // return and tab to two bytes each, and every other C0 control plus // LINE SEPARATOR and PARAGRAPH SEPARATOR to a six-byte \u escape; it // passes every other rune through as its own UTF-8. Its text handler // quotes with strconv.Quote, which spells a non-printable rune below // U+10000 as \uXXXX but one at or above U+10000 as \UXXXXXXXX — ten // bytes, not six. The text handler is therefore the worse of the two // for every non-printable rune, and by four bytes apiece for the // 955,086 unassigned, private-use and format code points on planes 1 // to 16. // // Charging ten there is what makes the stated per-line ceiling hold // for the tty handler as well: U+1000C encodes as F0 90 80 8C, every // byte >= 0x80, which httpguts.ValidHeaderFieldValue accepts and // net/textproto does not strip, so a header can be filled with them. // // Both handlers pass printable runes through as their own UTF-8, so // unicode.IsPrint separates the escaped cases from the plain ones for // either handler. func EncodedBytes(r rune) int { const ( // A backslash and the character itself. shortEscapeBytes = 2 // \uXXXX, which is also the width of \u00XX. escapedRuneBytes = 6 // \UXXXXXXXX, strconv.Quote's spelling of a non-printable // rune outside the basic multilingual plane. escapedAstralRuneBytes = 10 // The first code point strconv.Quote spells with \U. firstAstralRune = 0x10000 ) switch { case r == '"' || r == '\\' || r == '\n' || r == '\r' || r == '\t': return shortEscapeBytes case !unicode.IsPrint(r) && r >= firstAstralRune: return escapedAstralRuneBytes case !unicode.IsPrint(r): return escapedRuneBytes default: return utf8.RuneLen(r) } } // Truncate caps s at maxBytes of ENCODED output, marking the value // when it cuts. // // Budgeting raw bytes would not bound the line. Escaping only ever // grows a value, so a raw budget spent on characters the encoder // escapes buys a field several times its nominal size — and the line // is the thing an operator is told to multiply by their request rate. // Charging each rune what it will actually cost is what makes the // stated ceiling true rather than merely larger. The visible // consequence is that an escape-heavy value keeps a shorter prefix // than a plain one, which is the correct trade. // // The result is always valid UTF-8. A cut on a byte boundary can split // a multi-byte rune, and a header — or a SQL literal — can carry bytes // that were never valid UTF-8 to begin with; both are dropped rather // than kept, since an encoder would otherwise spend six bytes // replacing each one. func Truncate(s string, maxBytes int) string { // No rune encodes to fewer bytes than it occupies, so nothing past // maxBytes raw can fit the budget. Slicing first bounds the scan // below to the budget rather than to the size of the value the // client sent. window, cut := s, false if len(window) > maxBytes { window, cut = window[:maxBytes], true } var ( kept strings.Builder spent int ) for i := 0; i < len(window); { r, size := utf8.DecodeRuneInString(window[i:]) if r == utf8.RuneError && size == 1 { i += size continue } cost := EncodedBytes(r) if spent+cost > maxBytes { cut = true break } spent += cost kept.WriteString(window[i : i+size]) i += size } if !cut { return kept.String() } return kept.String() + truncationMarker }