From ba8401a3b1cd57fd85355c905c253d99519f3fa7 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Sun, 27 Sep 2026 16:11:52 +0000 Subject: [PATCH] fix(reconstruct): bound diagnostic text at a valid UTF-8 boundary bounded() sliced smtp_detail/failure_reason/failure_code/review_resolution at a raw byte offset with no rune-boundary check, unlike its sibling SafeDiagnostic(). Route it through SafeDiagnostic() so both share the same truncation rule. Fixes #1055 --- internal/messagelifecycle/reconstruct.go | 5 +--- internal/messagelifecycle/reconstruct_test.go | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/internal/messagelifecycle/reconstruct.go b/internal/messagelifecycle/reconstruct.go index 34e11af61..e7b34f4c1 100644 --- a/internal/messagelifecycle/reconstruct.go +++ b/internal/messagelifecycle/reconstruct.go @@ -624,10 +624,7 @@ func reconstructedID(messageID, sourceKind, sourceID, recipient string, reason R } func bounded(value string) string { - if len(value) <= maxDiagnosticStringBytes { - return value - } - return value[:maxDiagnosticStringBytes] + return SafeDiagnostic(value) } func cloneTransition(item MessageLifecycleTransition) MessageLifecycleTransition { diff --git a/internal/messagelifecycle/reconstruct_test.go b/internal/messagelifecycle/reconstruct_test.go index 0100b0bb8..680a2bf21 100644 --- a/internal/messagelifecycle/reconstruct_test.go +++ b/internal/messagelifecycle/reconstruct_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" "time" + "unicode/utf8" ) var reconstructBaseTime = time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC) @@ -181,6 +182,29 @@ func TestReconstructRecipientMappingsAndIgnoredStatuses(t *testing.T) { } } +func TestReconstructOversizedRecipientDetailIsBoundedAtAValidUTF8Boundary(t *testing.T) { + // A 3-byte rune repeated enough times that the byte cap (2048) lands + // mid-rune: 683*3 = 2049 bytes, and 2049%3 = 0 while 2048%3 = 2, so a raw + // byte slice at maxDiagnosticStringBytes always splits the last rune. + oversized := strings.Repeat("δΈ­", 683) + s := baseSnapshot("outbound", "smtp") + s.Recipients = []RecipientSnapshot{{ID: "rcp_1", Address: "a@example.com", Status: "bounced", Detail: oversized, UpdatedAt: reconstructBaseTime.Add(time.Minute)}} + got := findReason(Reconstruct(s), ReasonDeliveryUndeterminedBounce) + if got == nil { + t.Fatalf("recipient transition disappeared: %#v", Reconstruct(s)) + } + detail, ok := got.Evidence["smtp_detail"].(string) + if !ok { + t.Fatalf("smtp_detail missing or not a string: %#v", got.Evidence) + } + if len(detail) > maxDiagnosticStringBytes { + t.Fatalf("smtp_detail exceeds the byte cap: len=%d", len(detail)) + } + if !utf8.ValidString(detail) { + t.Fatalf("smtp_detail split a UTF-8 rune at the byte boundary: %q", detail) + } +} + func TestReconstructCausalSuppressionsOnly(t *testing.T) { s := baseSnapshot("outbound", "smtp") s.Suppressions = []SuppressionSnapshot{