Replace try! regex construction with a non-trapping SafeRegex helper (#1501)

MessageFormattingEngine and MessageDeduplicationService compiled eight
bundled regex literals with try!, so a bad pattern would crash the app
at startup - in the middle of the message-render path (#645).

Add SafeRegex.compile: it compiles the pattern normally, and on failure
logs through SecureLogger and returns a never-matching regex ('(?!)'),
so a broken pattern degrades that one formatting feature instead of
trapping. Pattern properties stay non-optional, so no call-site churn
across ChatMessageFormatter, MessageTextHelpers, and
ChatComposerCoordinator.

The compile-time guarantee try! provided moves into tests: each
production pattern is asserted to compile and match a known-good sample,
so a typo in a pattern now fails CI instead of crashing users.

Part of #645 (the remaining try! sites; NoiseSessionManager's
force-unwrap is addressed separately in #1456).
This commit is contained in:
Vidit Kulshrestha 2026-07-30 22:26:45 +05:30 committed by GitHub
parent 81837d7202
commit 4ef5558d7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 111 additions and 27 deletions

View File

@ -104,12 +104,10 @@ final class LRUDeduplicationCache<Value> {
enum ContentNormalizer {
/// Regex to simplify HTTP URLs by stripping query strings and fragments
private static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
options: [.caseInsensitive]
)
}()
private static let simplifyHTTPURL = SafeRegex.compile(
"https?://[^\\s?#]+(?:[?#][^\\s]*)?",
options: [.caseInsensitive]
)
/// Normalizes content for deduplication comparison.
/// - Parameters:

View File

@ -39,37 +39,23 @@ final class MessageFormattingEngine {
/// Precompiled regex patterns for message content parsing
enum Patterns {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let hashtag = SafeRegex.compile("#([a-zA-Z0-9_]+)")
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let mention = SafeRegex.compile("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let cashu = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let bolt11 = SafeRegex.compile("(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b")
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lnurl = SafeRegex.compile("(?i)\\blnurl1[a-z0-9]{20,}\\b")
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let lightningScheme = SafeRegex.compile("(?i)\\blightning:[^\\s]+")
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let quickCashuPresence = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
}
// MARK: - Match Types

View File

@ -0,0 +1,36 @@
//
// SafeRegex.swift
// bitchat
//
// Non-trapping construction for the app's compiled-in regex patterns.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
enum SafeRegex {
/// Compiles a bundled pattern. On failure it logs and returns a regex
/// that can never match, so a bad pattern degrades that one feature
/// instead of crashing at startup.
static func compile(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression {
do {
return try NSRegularExpression(pattern: pattern, options: options)
} catch {
SecureLogger.error("Regex pattern failed to compile, matching disabled: \(pattern) (\(error))", category: .session)
return neverMatching
}
}
/// `(?!)` an empty negative lookahead always compiles and can never match.
private static let neverMatching: NSRegularExpression = {
if let regex = try? NSRegularExpression(pattern: "(?!)", options: []) {
return regex
}
// Unreachable: "(?!)" is a valid ICU pattern. The inherited plain
// initializer (empty pattern) is the least-bad non-trapping fallback
// if ICU itself were ever broken.
return NSRegularExpression()
}()
}

View File

@ -0,0 +1,64 @@
//
// SafeRegexTests.swift
// bitchatTests
//
// SafeRegex must never trap: valid patterns compile normally, invalid ones
// degrade to a regex that matches nothing. The production-pattern test keeps
// the compile-time guarantee try! used to provide - a typo in any bundled
// pattern fails here instead of crashing the app at startup.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
struct SafeRegexTests {
private func matchCount(_ regex: NSRegularExpression, _ text: String) -> Int {
regex.numberOfMatches(in: text, options: [], range: NSRange(text.startIndex..., in: text))
}
@Test
func validPatternCompilesAndMatches() {
let regex = SafeRegex.compile("#([a-zA-Z0-9_]+)")
#expect(matchCount(regex, "tag #bitchat here") == 1)
}
@Test
func invalidPatternDegradesToNeverMatching() {
let regex = SafeRegex.compile("(unclosed")
#expect(matchCount(regex, "(unclosed anything") == 0)
#expect(matchCount(regex, "") == 0)
}
@Test
func productionPatternsCompileAndMatchTheirTargets() {
// A pattern that failed to compile would have degraded to
// never-matching, so each positive match proves the literal compiled.
#expect(matchCount(MessageFormattingEngine.Patterns.hashtag, "see #mesh") == 1)
#expect(matchCount(MessageFormattingEngine.Patterns.mention, "hi @alice#ab12") == 1)
let cashuToken = "cashuA" + String(repeating: "x", count: 45)
#expect(matchCount(MessageFormattingEngine.Patterns.cashu, cashuToken) == 1)
#expect(matchCount(MessageFormattingEngine.Patterns.quickCashuPresence, cashuToken) == 1)
let bolt11 = "lnbc1" + String(repeating: "q", count: 55)
#expect(matchCount(MessageFormattingEngine.Patterns.bolt11, bolt11) == 1)
let lnurl = "lnurl1" + String(repeating: "q", count: 25)
#expect(matchCount(MessageFormattingEngine.Patterns.lnurl, lnurl) == 1)
#expect(matchCount(MessageFormattingEngine.Patterns.lightningScheme, "pay lightning:abc123") == 1)
}
@Test
func contentNormalizerStillSimplifiesURLs() {
// Exercises ContentNormalizer's regex through its public entry point:
// same URL with different query strings must normalize identically.
let a = ContentNormalizer.normalizedKey("check https://example.com/page?q=1")
let b = ContentNormalizer.normalizedKey("check https://example.com/page?q=2")
#expect(a == b)
}
}