Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion assert/assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Comment thread
HarshalPatel1972 marked this conversation as resolved.
80 changes: 80 additions & 0 deletions assert/assertions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
HarshalPatel1972 marked this conversation as resolved.

// 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")
}