From eb0a59047d4f8895ebef352d42fa5718f0aa89e8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:35:56 -0600 Subject: [PATCH 1/4] Fix grapheme-vs-UTF-16 range bug in RichContentFormatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RichContentFormatter built its NSRanges from `content.count` (Swift grapheme count), but NSRegularExpression matches over UTF-16. With multi-code-unit characters (emoji, flags, combining sequences) the grapheme count is shorter than the UTF-16 length, so the search range was truncated and any tag or style near the end silently escaped stripping. removeTrailingBreakTags also fed a UTF-16 match offset to String.index(_:offsetBy:), which counts graphemes — right for ASCII, a crash once the range was corrected. resizeGalleryImageURL, in the display pipeline, carried the same confusion: it sized the src-rewrite range from `imgElementStr.count`, so a gallery image's src could slip past the range and never be swapped for its resized URL. Range over UTF-16 via `String.utf16.count`, and convert the trailing-BR match with Range(_:in:). Adds one isolated test per fix site — each forbidden-tag, div/paragraph, filterNewLines, inline-style, and trailing-break site, plus the trailing-break index-offset cut and the gallery-image src rewrite — using astral emoji, ZWJ sequences, flags, keycaps, skin-tone modifiers, and an NFD combining mark, so reverting any single site breaks exactly one test. Two further tests pin the exact off-by-one boundary and confirm the corrected range strips the intended tag rather than everything. Each fails on the old code and passes now, and the exact-output assertions confirm the clusters survive byte-for-byte. --- .../Utility/RichContentFormatter.swift | 29 +++-- ...RichContentFormatter+DisplayPipeline.swift | 2 +- .../RichContentFormatterTests.swift | 110 ++++++++++++++++++ .../RichContentFormatterUITests.swift | 18 +++ 4 files changed, 143 insertions(+), 16 deletions(-) diff --git a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift index 38530a2d2043..05e44922b3ca 100644 --- a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift +++ b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift @@ -50,17 +50,17 @@ import Foundation content = RegEx.styleTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") content = RegEx.scriptTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") content = RegEx.gutenbergComments.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") return content @@ -84,23 +84,23 @@ import Foundation // Convert div tags to p tags content = RegEx.divTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: openPTag) content = RegEx.divTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: closePTag) // Remove duplicate/redundant p tags. content = RegEx.pTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: openPTag) content = RegEx.pTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: closePTag) content = filterNewLines(content) @@ -114,11 +114,11 @@ import Foundation var ranges = [NSRange]() // We don't want to remove new lines from preformatted tag blocks, // so get the ranges of such blocks. - let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count)) + let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count)) if matches.isEmpty { // No blocks found, so we'll parse the whole string. - ranges.append(NSRange(location: 0, length: content.count)) + ranges.append(NSRange(location: 0, length: content.utf16.count)) } else { // One or more preformatted blocks found, we don't want to remove new lines @@ -133,7 +133,7 @@ import Foundation location = match.range.location + match.range.length } - length = content.count - location + length = content.utf16.count - location ranges.append(NSRange(location: location, length: length)) } @@ -163,7 +163,7 @@ import Foundation content = RegEx.styleAttr.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") return content @@ -206,10 +206,9 @@ import Foundation } var content = string.trim() - let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count)) - if let match = matches.first { - let index = content.index(content.startIndex, offsetBy: match.range.location) - content = String(content.prefix(upTo: index)) + let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count)) + if let match = matches.first, let matchRange = Range(match.range, in: content) { + content = String(content[.. block after a flag emoji is stripped; the neighbouring stays. + let out = RichContentFormatter.removeForbiddenTags("🇺🇸hi") + XCTAssertEqual(out, "🇺🇸hi") + } + + func testZWJFamilyScriptTagSurvivesInTail() { + // A ") + XCTAssertEqual(out, "👨‍👩‍👧‍👦") + } + + func testKeycapGutenbergCommentSurvivesInTail() { + // A Gutenberg block comment after a keycap emoji is stripped. + let out = RichContentFormatter.removeForbiddenTags("1️⃣

") + XCTAssertEqual(out, "1️⃣") + } + + func testSkinToneDivStartNotConvertedInTail() { + //
is converted to

even after a skin-tone emoji. + let out = RichContentFormatter.normalizeParagraphs("👍🏽

") + XCTAssertEqual(out, "👍🏽

") + } + + func testNFDCombiningDivEndNotConvertedInTail() { + //

is converted to

after a decomposed "é" (e + a combining accent). A composed + // "é" is a single UTF-16 unit and would not reach past the range, so the decomposition matters. + let out = RichContentFormatter.normalizeParagraphs("cafe\u{301}
") + XCTAssertEqual(out, "cafe\u{301}

") + } + + func testNormalizeParagraphsMergesTrailingDoubleOpenParagraph() { + // A redundant

is collapsed to a single

. + let out = RichContentFormatter.normalizeParagraphs("😀

") + XCTAssertEqual(out, "😀

") + } + + func testNormalizeParagraphsMergesTrailingDoubleCloseParagraph() { + // A redundant

is collapsed to a single

. + let out = RichContentFormatter.normalizeParagraphs("😀

") + XCTAssertEqual(out, "😀

") + } + + func testFilterNewLinesNoPreFallbackRemovesNewlinePastWideCluster() { + // A newline outside any
 block is removed.
+        let out = RichContentFormatter.filterNewLines("👨‍👩‍👧‍👦\nA")
+        XCTAssertEqual(out, "👨‍👩‍👧‍👦A")
+    }
+
+    func testFilterNewLinesElseBranchPreservesTrailingNewlineAfterWideCluster() {
+        // With a 
 block present, a newline that follows it (outside the block) is still removed.
+        let out = RichContentFormatter.filterNewLines("
\n
👨‍👩‍👧‍👦\nZ") + XCTAssertEqual(out, "
\n
👨‍👩‍👧‍👦Z") + } + + func testFilterNewLinesMultiPreInverseRanges() { + // Across several
 blocks: newlines inside them are kept, newlines outside are removed.
+        let out = RichContentFormatter.filterNewLines("👨‍👩‍👧‍👦\n
a\nb
\n😀\n
c\nd
\n🇺🇸\n") + XCTAssertEqual(out, "👨‍👩‍👧‍👦
a\nb
😀
c\nd
🇺🇸") + } + + func testZWJFamilyStyleAttrSurvivesInTruncatedTail() { + // An inline style attribute after a family emoji is stripped. + let out = RichContentFormatter.removeInlineStyles("👨‍👩‍👧‍👦
") + XCTAssertEqual(out, "👨‍👩‍👧‍👦
") + } + + func testZWJFamilyTrailingBreakSurvivesAndCutsCleanly() { + // A trailing
after a family emoji is removed, and the emoji before it stays intact. + let out = RichContentFormatter.removeTrailingBreakTags("👨‍👩‍👧‍👦text
") + XCTAssertEqual(out, "👨‍👩‍👧‍👦text") + } + + func testTrailingBreakOnlyFinalRemovedEmojiIntact() { + // Only the trailing
is removed; an earlier
in the middle of the text stays. + let out = RichContentFormatter.removeTrailingBreakTags("😀
text
") + XCTAssertEqual(out, "😀
text") + } + + func testForbiddenCleanMultibyteUnchanged() { + // Content with no tags to strip passes through unchanged. + let out = RichContentFormatter.removeForbiddenTags("Hello 👨‍👩‍👧‍👦 world 😀!") + XCTAssertEqual(out, "Hello 👨‍👩‍👧‍👦 world 😀!") + } + + // MARK: - Boundary + selectivity (not new fix sites) + + func testBoundaryStraddleOffByOne() { + // One emoji makes the range exactly one UTF-16 unit short, and the token's closing ">" + // is exactly that dropped unit — pins the off-by-one where the wide-gap cases have slack. + let out = RichContentFormatter.removeForbiddenTags("text") + XCTAssertEqual(out, "text") + } + + func testStripsTagInRangeAndInTailNotJustEverything() { + // The first style attribute is always in range; the ZWJ family pushes the second into the + // truncated tail. The fix strips both; the bug strips only the first — so the range, not a + // blanket "strip everything", decides which tags go. + let out = RichContentFormatter.removeInlineStyles("👨‍👩‍👧‍👦") + XCTAssertEqual(out, "👨‍👩‍👧‍👦") + } } diff --git a/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift b/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift index 8efaddb06f04..758b20ee9dd8 100644 --- a/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift +++ b/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift @@ -7,4 +7,22 @@ class RichContentFormatterUITests: XCTestCase { func testResizeGalleryImageURLsForContentEmptyString() { XCTAssertTrue("" == RichContentFormatter.resizeGalleryImageURL("", isPrivateSite: false)) } + + // The gallery-image src rewrite sized its search range from the grapheme count + // (`imgElementStr.count`) rather than the UTF-16 length, so a `src` sitting past a + // multi-code-unit cluster fell outside the range and was never swapped for the resized + // URL. Here five emoji in `alt` (10 UTF-16 units, 5 graphemes) push the trailing `src` + // past a grapheme-count range; the resized URL must still replace it, cluster intact. + func testResizeGalleryImageURLReplacesSrcPastMultibyteCluster() { + let input = + "\"😀😀😀😀😀\"" + + let output = RichContentFormatter.resizeGalleryImageURL(input, isPrivateSite: false) + + // The original src was found and rewritten to a resized (Photon) URL... + XCTAssertFalse(output.contains("https://example.com/small.jpg")) + XCTAssertTrue(output.contains(".wp.com")) + // ...and the emoji cluster survived byte-for-byte. + XCTAssertTrue(output.contains("😀😀😀😀😀")) + } } From ab01b581311469e7c6b97940899f49fc828d4c41 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:15:31 -0600 Subject: [PATCH 2/4] Migrate RichContentFormatter tests to Swift Testing Convert RichContentFormatterTests and RichContentFormatterUITests from XCTest to Swift Testing (@Test / #expect), matching the rest of the WordPressSharedTests target. Same inputs and assertions; no coverage change. --- .../RichContentFormatterTests.swift | 112 +++++++++--------- .../RichContentFormatterUITests.swift | 18 +-- 2 files changed, 68 insertions(+), 62 deletions(-) diff --git a/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift b/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift index 7688ca1ce588..d30600871880 100644 --- a/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift +++ b/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift @@ -1,69 +1,73 @@ -import XCTest +import Foundation +import Testing + @testable import WordPressShared -class RichContentFormatterTests: XCTestCase { +struct RichContentFormatterTests { - func testRemoveInlineStyles() { + @Test func testRemoveInlineStyles() { let str = "

test

test

" let styleStr = "

test

test

" let sanitizedStr = RichContentFormatter.removeInlineStyles(styleStr) - XCTAssertTrue(str == sanitizedStr, "The inline styles were not removed.") + #expect(str == sanitizedStr, "The inline styles were not removed.") } - func testRemoveForbiddenTags() { + @Test func testRemoveForbiddenTags() { let str = "

test

test

" - let styleStr = "

test

test

\n

" + let styleStr = + "

test

test

\n

" let sanitizedStr = RichContentFormatter.removeForbiddenTags(styleStr) - XCTAssertTrue(str == sanitizedStr, "The forbidden tags were not removed.") + #expect(str == sanitizedStr, "The forbidden tags were not removed.") } - func testNormalizeParagraphs() { + @Test func testNormalizeParagraphs() { let str = "

test

\n\ntest\n\n

test

" let styleStr = "

test

\n\ntest\n\n
\n

test

\n" let sanitizedStr = RichContentFormatter.normalizeParagraphs(styleStr) - XCTAssertTrue(str == sanitizedStr, "Not all paragraphs were normalized.") + #expect(str == sanitizedStr, "Not all paragraphs were normalized.") } - func testFilterNewLines() { + @Test func testFilterNewLines() { let str = "

test

\n\ntest\n\n

test

" let styleStr = "

test

\n\ntest\n\n
\n

test

\n" let sanitizedStr = RichContentFormatter.filterNewLines(styleStr) - XCTAssertTrue(str == sanitizedStr, "Not all paragraphs were normalized.") + #expect(str == sanitizedStr, "Not all paragraphs were normalized.") } - func testRemoveTrailingBRTags() { + @Test func testRemoveTrailingBRTags() { let str = "

test


test

" let styleStr = "

test


test



" let sanitizedStr = RichContentFormatter.removeTrailingBreakTags(styleStr) - XCTAssertTrue(str == sanitizedStr, "The inline styles were not removed.") + #expect(str == sanitizedStr, "The inline styles were not removed.") } - func testRemoveGutenbergGalleryListMarkup() { - let str = "Some text. Some text." + @Test func testRemoveGutenbergGalleryListMarkup() { + let str = + "Some text. Some text." let sanitizedString = RichContentFormatter.formatGutenbergGallery(str) as NSString // Checks if the UL was removed. var range = sanitizedString.range(of: "block-gallery") - XCTAssertTrue(range.location == NSNotFound) + #expect(range.location == NSNotFound) // Checks if the LI was removed range = sanitizedString.range(of: "blocks-gallery") - XCTAssertTrue(range.location == NSNotFound) + #expect(range.location == NSNotFound) // Checks if the FIGCAPTION was kept. range = sanitizedString.range(of: "figcaption") - XCTAssertTrue(range.location != NSNotFound) + #expect(range.location != NSNotFound) } - func testFormatVideoTags() { + @Test func testFormatVideoTags() { let str1 = "

Some text.

Some text.

" let sanitizedStr1 = RichContentFormatter.formatVideoTags(str1) as NSString - XCTAssert(sanitizedStr1.contains("controls")) + #expect(sanitizedStr1.contains("controls")) let str2 = "

Some text.

Some text.

" let sanitizedStr2 = RichContentFormatter.formatVideoTags(str2) as NSString - XCTAssert(sanitizedStr2.contains(" controls ")) + #expect(sanitizedStr2.contains(" controls ")) let str3 = "

Some text.

Some text.

" let sanitizedStr3 = RichContentFormatter.formatVideoTags(str3) as NSString - XCTAssert(!sanitizedStr3.contains("controls controls")) + #expect(!sanitizedStr3.contains("controls controls")) } // MARK: - Multi-code-unit input @@ -74,105 +78,105 @@ class RichContentFormatterTests: XCTestCase { // a token near the end of the string just past the range, so the search never reaches it. // Each test drives one such spot; the exact-output check also confirms the cluster is intact. - func testRegionalFlagStyleBlockSurvivesInTail() { + @Test func testRegionalFlagStyleBlockSurvivesInTail() { // A ") - XCTAssertEqual(out, "🇺🇸hi") + #expect(out == "🇺🇸hi") } - func testZWJFamilyScriptTagSurvivesInTail() { + @Test func testZWJFamilyScriptTagSurvivesInTail() { // A ") - XCTAssertEqual(out, "👨‍👩‍👧‍👦") + #expect(out == "👨‍👩‍👧‍👦") } - func testKeycapGutenbergCommentSurvivesInTail() { + @Test func testKeycapGutenbergCommentSurvivesInTail() { // A Gutenberg block comment after a keycap emoji is stripped. let out = RichContentFormatter.removeForbiddenTags("1️⃣

") - XCTAssertEqual(out, "1️⃣") + #expect(out == "1️⃣") } - func testSkinToneDivStartNotConvertedInTail() { + @Test func testSkinToneDivStartNotConvertedInTail() { //
is converted to

even after a skin-tone emoji. let out = RichContentFormatter.normalizeParagraphs("👍🏽

") - XCTAssertEqual(out, "👍🏽

") + #expect(out == "👍🏽

") } - func testNFDCombiningDivEndNotConvertedInTail() { + @Test func testNFDCombiningDivEndNotConvertedInTail() { //

is converted to

after a decomposed "é" (e + a combining accent). A composed // "é" is a single UTF-16 unit and would not reach past the range, so the decomposition matters. let out = RichContentFormatter.normalizeParagraphs("cafe\u{301}
") - XCTAssertEqual(out, "cafe\u{301}

") + #expect(out == "cafe\u{301}

") } - func testNormalizeParagraphsMergesTrailingDoubleOpenParagraph() { + @Test func testNormalizeParagraphsMergesTrailingDoubleOpenParagraph() { // A redundant

is collapsed to a single

. let out = RichContentFormatter.normalizeParagraphs("😀

") - XCTAssertEqual(out, "😀

") + #expect(out == "😀

") } - func testNormalizeParagraphsMergesTrailingDoubleCloseParagraph() { + @Test func testNormalizeParagraphsMergesTrailingDoubleCloseParagraph() { // A redundant

is collapsed to a single

. let out = RichContentFormatter.normalizeParagraphs("😀

") - XCTAssertEqual(out, "😀

") + #expect(out == "😀

") } - func testFilterNewLinesNoPreFallbackRemovesNewlinePastWideCluster() { + @Test func testFilterNewLinesNoPreFallbackRemovesNewlinePastWideCluster() { // A newline outside any
 block is removed.
         let out = RichContentFormatter.filterNewLines("👨‍👩‍👧‍👦\nA")
-        XCTAssertEqual(out, "👨‍👩‍👧‍👦A")
+        #expect(out == "👨‍👩‍👧‍👦A")
     }
 
-    func testFilterNewLinesElseBranchPreservesTrailingNewlineAfterWideCluster() {
+    @Test func testFilterNewLinesElseBranchPreservesTrailingNewlineAfterWideCluster() {
         // With a 
 block present, a newline that follows it (outside the block) is still removed.
         let out = RichContentFormatter.filterNewLines("
\n
👨‍👩‍👧‍👦\nZ") - XCTAssertEqual(out, "
\n
👨‍👩‍👧‍👦Z") + #expect(out == "
\n
👨‍👩‍👧‍👦Z") } - func testFilterNewLinesMultiPreInverseRanges() { + @Test func testFilterNewLinesMultiPreInverseRanges() { // Across several
 blocks: newlines inside them are kept, newlines outside are removed.
         let out = RichContentFormatter.filterNewLines("👨‍👩‍👧‍👦\n
a\nb
\n😀\n
c\nd
\n🇺🇸\n") - XCTAssertEqual(out, "👨‍👩‍👧‍👦
a\nb
😀
c\nd
🇺🇸") + #expect(out == "👨‍👩‍👧‍👦
a\nb
😀
c\nd
🇺🇸") } - func testZWJFamilyStyleAttrSurvivesInTruncatedTail() { + @Test func testZWJFamilyStyleAttrSurvivesInTruncatedTail() { // An inline style attribute after a family emoji is stripped. let out = RichContentFormatter.removeInlineStyles("👨‍👩‍👧‍👦
") - XCTAssertEqual(out, "👨‍👩‍👧‍👦
") + #expect(out == "👨‍👩‍👧‍👦
") } - func testZWJFamilyTrailingBreakSurvivesAndCutsCleanly() { + @Test func testZWJFamilyTrailingBreakSurvivesAndCutsCleanly() { // A trailing
after a family emoji is removed, and the emoji before it stays intact. let out = RichContentFormatter.removeTrailingBreakTags("👨‍👩‍👧‍👦text
") - XCTAssertEqual(out, "👨‍👩‍👧‍👦text") + #expect(out == "👨‍👩‍👧‍👦text") } - func testTrailingBreakOnlyFinalRemovedEmojiIntact() { + @Test func testTrailingBreakOnlyFinalRemovedEmojiIntact() { // Only the trailing
is removed; an earlier
in the middle of the text stays. let out = RichContentFormatter.removeTrailingBreakTags("😀
text
") - XCTAssertEqual(out, "😀
text") + #expect(out == "😀
text") } - func testForbiddenCleanMultibyteUnchanged() { + @Test func testForbiddenCleanMultibyteUnchanged() { // Content with no tags to strip passes through unchanged. let out = RichContentFormatter.removeForbiddenTags("Hello 👨‍👩‍👧‍👦 world 😀!") - XCTAssertEqual(out, "Hello 👨‍👩‍👧‍👦 world 😀!") + #expect(out == "Hello 👨‍👩‍👧‍👦 world 😀!") } // MARK: - Boundary + selectivity (not new fix sites) - func testBoundaryStraddleOffByOne() { + @Test func testBoundaryStraddleOffByOne() { // One emoji makes the range exactly one UTF-16 unit short, and the token's closing ">" // is exactly that dropped unit — pins the off-by-one where the wide-gap cases have slack. let out = RichContentFormatter.removeForbiddenTags("text") - XCTAssertEqual(out, "text") + #expect(out == "text") } - func testStripsTagInRangeAndInTailNotJustEverything() { + @Test func testStripsTagInRangeAndInTailNotJustEverything() { // The first style attribute is always in range; the ZWJ family pushes the second into the // truncated tail. The fix strips both; the bug strips only the first — so the range, not a // blanket "strip everything", decides which tags go. let out = RichContentFormatter.removeInlineStyles("
👨‍👩‍👧‍👦") - XCTAssertEqual(out, "👨‍👩‍👧‍👦") + #expect(out == "👨‍👩‍👧‍👦") } } diff --git a/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift b/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift index 758b20ee9dd8..b123ae42bb50 100644 --- a/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift +++ b/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift @@ -1,11 +1,13 @@ -import XCTest +import Foundation +import Testing + @testable import WordPressShared @testable import WordPressSharedUI -class RichContentFormatterUITests: XCTestCase { +struct RichContentFormatterUITests { - func testResizeGalleryImageURLsForContentEmptyString() { - XCTAssertTrue("" == RichContentFormatter.resizeGalleryImageURL("", isPrivateSite: false)) + @Test func testResizeGalleryImageURLsForContentEmptyString() { + #expect(RichContentFormatter.resizeGalleryImageURL("", isPrivateSite: false).isEmpty) } // The gallery-image src rewrite sized its search range from the grapheme count @@ -13,16 +15,16 @@ class RichContentFormatterUITests: XCTestCase { // multi-code-unit cluster fell outside the range and was never swapped for the resized // URL. Here five emoji in `alt` (10 UTF-16 units, 5 graphemes) push the trailing `src` // past a grapheme-count range; the resized URL must still replace it, cluster intact. - func testResizeGalleryImageURLReplacesSrcPastMultibyteCluster() { + @Test func testResizeGalleryImageURLReplacesSrcPastMultibyteCluster() { let input = "\"😀😀😀😀😀\"" let output = RichContentFormatter.resizeGalleryImageURL(input, isPrivateSite: false) // The original src was found and rewritten to a resized (Photon) URL... - XCTAssertFalse(output.contains("https://example.com/small.jpg")) - XCTAssertTrue(output.contains(".wp.com")) + #expect(!output.contains("https://example.com/small.jpg")) + #expect(output.contains(".wp.com")) // ...and the emoji cluster survived byte-for-byte. - XCTAssertTrue(output.contains("😀😀😀😀😀")) + #expect(output.contains("😀😀😀😀😀")) } } From 53a1bf308dbd0aa9080a4f2ef45c2bf2729b7ecf Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:20:38 -0600 Subject: [PATCH 3/4] Introduce String.fullNSRange for whole-string search ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RichContentFormatter built the same whole-string NSRange — in UTF-16 — at eleven call sites. Extract it as an internal `String.fullNSRange` so the grapheme-vs-UTF-16 mistake this branch fixes can't quietly reappear: `NSRange(location: 0, length: count)` is no longer hand-written where the UTF-16 length is required. No behavior change — fullNSRange is exactly NSRange(location: 0, length: utf16.count). --- .../Utility/RichContentFormatter.swift | 22 +++++++++---------- .../Utility/String+FullNSRange.swift | 14 ++++++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 Modules/Sources/WordPressShared/Utility/String+FullNSRange.swift diff --git a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift index 05e44922b3ca..c5d721dcadf6 100644 --- a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift +++ b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift @@ -50,17 +50,17 @@ import Foundation content = RegEx.styleTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.utf16.count), + range: content.fullNSRange, withTemplate: "") content = RegEx.scriptTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.utf16.count), + range: content.fullNSRange, withTemplate: "") content = RegEx.gutenbergComments.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.utf16.count), + range: content.fullNSRange, withTemplate: "") return content @@ -84,23 +84,23 @@ import Foundation // Convert div tags to p tags content = RegEx.divTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.utf16.count), + range: content.fullNSRange, withTemplate: openPTag) content = RegEx.divTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.utf16.count), + range: content.fullNSRange, withTemplate: closePTag) // Remove duplicate/redundant p tags. content = RegEx.pTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.utf16.count), + range: content.fullNSRange, withTemplate: openPTag) content = RegEx.pTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.utf16.count), + range: content.fullNSRange, withTemplate: closePTag) content = filterNewLines(content) @@ -114,11 +114,11 @@ import Foundation var ranges = [NSRange]() // We don't want to remove new lines from preformatted tag blocks, // so get the ranges of such blocks. - let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count)) + let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: content.fullNSRange) if matches.isEmpty { // No blocks found, so we'll parse the whole string. - ranges.append(NSRange(location: 0, length: content.utf16.count)) + ranges.append(content.fullNSRange) } else { // One or more preformatted blocks found, we don't want to remove new lines @@ -163,7 +163,7 @@ import Foundation content = RegEx.styleAttr.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.utf16.count), + range: content.fullNSRange, withTemplate: "") return content @@ -206,7 +206,7 @@ import Foundation } var content = string.trim() - let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count)) + let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: content.fullNSRange) if let match = matches.first, let matchRange = Range(match.range, in: content) { content = String(content[.. Date: Tue, 1 Sep 2026 19:34:56 -0600 Subject: [PATCH 4/4] Harden parseValueForAttribute against a missing closing quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseValueForAttribute located an attribute's closing quote and fed the result straight into substring(with:). When the closing quote is absent — malformed markup with an opening quote and no close — the search returns NSNotFound, so the range length underflowed to NSIntegerMax and crashed with an out-of-bounds NSRange. Guard on the closing quote and return "" when it's missing, matching the attribute-not-found default. --- .../Utility/RichContentFormatter.swift | 4 +++- .../RichContentFormatterTests.swift | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift index c5d721dcadf6..c9eca4825392 100644 --- a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift +++ b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift @@ -187,7 +187,9 @@ import Foundation let location = attrRange.location + attrRange.length let length = elementStr.length - location let ending = elementStr.range(of: "\"", options: .caseInsensitive, range: NSRange(location: location, length: length)) - value = elementStr.substring(with: NSRange(location: location, length: ending.location - location)) + if ending.location != NSNotFound { + value = elementStr.substring(with: NSRange(location: location, length: ending.location - location)) + } } return value diff --git a/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift b/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift index d30600871880..bdc88c7910d7 100644 --- a/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift +++ b/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift @@ -179,4 +179,23 @@ struct RichContentFormatterTests { let out = RichContentFormatter.removeInlineStyles("👨‍👩‍👧‍👦") #expect(out == "👨‍👩‍👧‍👦") } + + // MARK: - parseValueForAttribute robustness + + @Test func testParseValueForAttributeReturnsValue() { + let value = RichContentFormatter.parseValueForAttribute("src", inElement: "") + #expect(value == "http://x/a.jpg") + } + + @Test func testParseValueForAttributeMissingClosingQuoteReturnsEmpty() { + // Opening quote but no closing quote: the closing-quote search returns NSNotFound, so the + // range length would underflow to a huge value and crash substring(with:). Return "" instead. + let value = RichContentFormatter.parseValueForAttribute("src", inElement: "") + #expect(value.isEmpty) + } + + @Test func testParseValueForAttributeAbsentReturnsEmpty() { + let value = RichContentFormatter.parseValueForAttribute("src", inElement: "\"x\"") + #expect(value.isEmpty) + } }