diff --git a/assert/assertions.go b/assert/assertions.go index 1419e4776..6a3be8655 100644 --- a/assert/assertions.go +++ b/assert/assertions.go @@ -931,7 +931,15 @@ func containsElement(list interface{}, element interface{}) (ok, found bool) { if listKind == reflect.String { elementValue := reflect.ValueOf(element) - return true, strings.Contains(listValue.String(), elementValue.String()) + haystack := listValue.String() + needle := elementValue.String() + // Fast-gate: if the bytes aren't present at all, skip the rune + // conversion entirely β€” no rune boundary can be falsely straddled + // when there are no matching bytes. + if !strings.Contains(haystack, needle) { + return true, false + } + return true, runeSliceContains([]rune(haystack), []rune(needle)) } if listKind == reflect.Map { @@ -2312,3 +2320,28 @@ func buildErrorChainString(err error, withType bool) string { } return chain } + +// runeSliceContains reports whether needle appears as a contiguous sub-slice +// of haystack. Comparisons are performed rune-by-rune, so the search is safe +// across multi-byte Unicode boundaries. +func runeSliceContains(haystack, needle []rune) bool { + if len(needle) == 0 { + return true + } + if len(needle) > len(haystack) { + return false + } + for i := 0; i <= len(haystack)-len(needle); i++ { + match := true + for j := 0; j < len(needle); j++ { + if haystack[i+j] != needle[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/assert/assertions_test.go b/assert/assertions_test.go index 11642e096..14b02b0ca 100644 --- a/assert/assertions_test.go +++ b/assert/assertions_test.go @@ -4217,3 +4217,83 @@ func TestNotErrorAsWithErrorTooLongToPrint(t *testing.T) { in chain: "long: [0 0 0`) Contains(t, mockT.errorString(), "<... truncated>") } + +// Verify Contains correctly protects against boundary-straddling false positives. +func TestContainsUnicode(t *testing.T) { + t.Parallel() + mockT := new(mockTestingT) + + // Each emoji is a 4-byte UTF-8 sequence. + // "🌟" = 0xF0 0x9F 0x8C 0x9F + // "πŸŽ‰" = 0xF0 0x9F 0x8E 0x89 + // The two-byte sequence "\x9f\x8e" is formed by the last byte of 🌟 + // and the second byte of πŸŽ‰. strings.Contains would find it; runeSliceContains + // must NOT, because "\x9f\x8e" does not correspond to any rune in the string. + haystack := "πŸŒŸπŸŽ‰" + byteFragment := "\x9f\x8e" // straddles the rune boundary between the two emojis + + // Confirm the fragment is NOT a valid rune sequence (sanity check). + // The real assertion: Contains must return false for a byte-only match. + False(t, Contains(mockT, haystack, byteFragment), + "Contains should not match a raw byte fragment that straddles a rune boundary") + + // Positive cases: real rune substrings must still be found. + True(t, Contains(mockT, haystack, "🌟"), + "Contains should find an exact emoji in the string") + True(t, Contains(mockT, haystack, "πŸŽ‰"), + "Contains should find an exact emoji in the string") + True(t, Contains(mockT, haystack, "πŸŒŸπŸŽ‰"), + "Contains should find the full emoji string") + + // Empty needle is always found. + True(t, Contains(mockT, haystack, ""), + "Contains should find an empty string in any string") + + // ASCII inside a Unicode string still works. + True(t, Contains(mockT, "hello 🌍 world", "world"), + "Contains should find ASCII substring in a Unicode string") + False(t, Contains(mockT, "hello 🌍 world", "earth"), + "Contains should not find absent ASCII substring") + + // Multi-byte CJK characters. + True(t, Contains(mockT, "ζ—₯本θͺžγƒ†γ‚Ήγƒˆ", "γƒ†γ‚Ήγƒˆ"), + "Contains should find a CJK substring") + False(t, Contains(mockT, "ζ—₯本θͺžγƒ†γ‚Ήγƒˆ", "δΈ­ζ–‡"), + "Contains should not find absent CJK substring") +} + +// TestElementsMatchUnicode verifies that ElementsMatch correctly compares +// slices whose elements are multi-byte Unicode strings (emojis, CJK, accented +// characters). Because diffLists delegates to ObjectsAreEqual β†’ reflect.DeepEqual +// for whole-string element comparison, these cases already work correctly; this +// test documents and locks in that behaviour. +func TestElementsMatchUnicode(t *testing.T) { + t.Parallel() + mockT := new(mockTestingT) + + // Emoji elements β€” order should not matter. + True(t, ElementsMatch(mockT, []string{"πŸŽ‰", "🌟", "🌍"}, []string{"🌍", "πŸŽ‰", "🌟"}), + "ElementsMatch should match emoji slices regardless of order") + + // Duplicate emoji elements must also match count-for-count. + True(t, ElementsMatch(mockT, []string{"πŸŽ‰", "πŸŽ‰", "🌟"}, []string{"🌟", "πŸŽ‰", "πŸŽ‰"}), + "ElementsMatch should respect duplicate emoji counts") + + // Mismatched emoji slices must not match. + False(t, ElementsMatch(mockT, []string{"πŸŽ‰", "🌟"}, []string{"πŸŽ‰", "🌍"}), + "ElementsMatch should reject slices with different emoji elements") + + // CJK characters. + True(t, ElementsMatch(mockT, []string{"ζ—₯本θͺž", "γƒ†γ‚Ήγƒˆ"}, []string{"γƒ†γ‚Ήγƒˆ", "ζ—₯本θͺž"}), + "ElementsMatch should match CJK string slices regardless of order") + + // Accented Latin characters. + True(t, ElementsMatch(mockT, []string{"cafΓ©", "naΓ―ve", "rΓ©sumΓ©"}, []string{"rΓ©sumΓ©", "cafΓ©", "naΓ―ve"}), + "ElementsMatch should match accented Latin strings regardless of order") + + // Mixed ASCII and Unicode. + True(t, ElementsMatch(mockT, + []string{"hello", "🌍", "δΈ–η•Œ"}, + []string{"δΈ–η•Œ", "hello", "🌍"}), + "ElementsMatch should match mixed ASCII/Unicode slices") +}