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[.. even after a skin-tone emoji.
+ let out = RichContentFormatter.normalizeParagraphs("👍🏽 ")
+ }
+
+ func testNFDCombiningDivEndNotConvertedInTail() {
+ //
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("👨👩👧👦\na\nb
\n😀\nc\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
\ntest\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
\ntest\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. 
Plants


Some text."
+ @Test func testRemoveGutenbergGalleryListMarkup() {
+ let str =
+ "Some text. 
Plants


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("👨👩👧👦\na\nb
\n😀\nc\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: "
")
+ #expect(value.isEmpty)
+ }
}