Skip to content

Commit 5906cfb

Browse files
kulkarni-rohanmeta-codesync[bot]
authored andcommitted
Fix Text truncation at NULL character on iOS and Android (#24129) (#57906)
Summary: Fixes #24129 **Problem:** `<Text>{'Hello \u0000 World'}</Text>` renders as "Hello" (truncated at \u0000). Does NOT reproduce when Debug JS Remotely (JSON preserves \u0000), but reproduces in production Hermes+Fabric on iOS and Android. **Root cause:** JS String preserves \u0000 (length includes it), C++ std::string from JSI utf8() also preserves via size. Truncation happens at platform bridge where C-string NUL-terminated APIs are used: - iOS: RCTAttributedTextUtils.mm:420 stringWithUTF8String:fragment.string.c_str() stops at embedded \0 - iOS: RCTConversions.h RCTNSStringFromString via stringWithCString: and reverse std::string{UTF8String} - iOS: RCTTurboModule.mm convertJSIStringToNSString same - Android: JavaTurboModule.cpp NewStringUTF(c_str()) expects NUL-terminated **Fix (no new API):** Replace the C-string APIs with length-aware equivalents. On iOS, `[[NSString alloc] initWithBytes:length:encoding:]` for std::string to NSString, and `dataUsingEncoding:` for the reverse, matching the existing correct pattern in FollyConvert.mm:31 and MapBufferBuilder.cpp. On Android the conversion now goes through UTF-16 in both directions rather than UTF-8: - outbound: `rt.utf16(...)` then `NewString(jchar*, len)` - inbound: `GetStringLength()` + `GetStringChars()` then `jsi::String::createFromUtf16(...)`, released with `ReleaseStringChars` That is worth calling out because it fixes a second latent bug. `NewStringUTF` expects *modified* UTF-8 (CESU-8), so it already mishandled 4-byte sequences such as emoji and other supplementary-plane characters. Going UTF-16 to UTF-16 avoids both problems. All surfaces (Text, TextInput, accessibility, TurboModule params) are fixed at once because the shared converters are fixed. ## NOTE: nil becomes empty string at two call sites RCTTurboModule.mm and RCTAttributedTextUtils.mm gain a `?: @""` fallback. Previously `stringWithUTF8String:` returned `nil` for invalid UTF-8 and callers received nil; they now receive `@""`. This matches the fallback `RCTNSStringFromString` already had, and `@""` is safer than nil for the ObjC call sites involved, but it is a behaviour change rather than a pure refactor. ## Changelog: [GENERAL] [FIXED] - Fix Text truncation when string contains NULL character \u0000 (#24129) Pull Request resolved: #57906 Test Plan: **Reproduction (RNTester):** - Add screen Text > NullCharacter with <Text>{'Hello\u0000World'}</Text> and 'A\u0000B\u0000C' - Before: "Hello" truncated - After: "HelloWorld" full (invisible \0 zero-width but World visible, length preserved) Closes #24129 ## Added on import: regression tests Four cases added to the existing `React/Tests/Text/RCTAttributedTextUtilsTest.mm`, which is owned by `TextTestsApple`: ``` buck2 test fbsource//xplat/js/react-native-github:TextTestsApple → Pass 31. Fail 0. ``` Restoring the pre-fix `RCTConversions.h` and `RCTAttributedTextUtils.mm` and re-running fails all four and nothing else: ``` ✗ RCTAttributedTextUtilsTest/testNSStringFromStringPreservesEmbeddedNull ✗ RCTAttributedTextUtilsTest/testStringFromNSStringPreservesEmbeddedNull ✗ RCTAttributedTextUtilsTest/testStringConversionRoundTripsEmbeddedNull ✗ RCTAttributedTextUtilsTest/testAttributedStringFromFragmentPreservesEmbeddedNull Tests finished: Pass 27. Fail 4. ``` They cover `RCTNSStringFromString`, `RCTStringFromNSString`, a round trip through both, and the real AttributedString to NSAttributedString path. The changed function in RCTAttributedTextUtils.mm (`RCTNSAttributedStringFragmentFromFragment`) is static, so that last one goes through the public `RCTNSAttributedStringFromAttributedString`. This replaces the original `node -p "'a\u0000b'.length"` check, which exercised JavaScript string length rather than any of the changed code. The Android hunks are not covered by these tests. They are iOS-only test targets and there is no equivalent JNI-level unit test in tree, so the Android side rests on code review. ## On the reverse-direction allocation `RCTStringFromNSString` now allocates an `NSData` where it previously used `UTF8String` (an interior pointer). That converter has 6 callers. The hot one is `RCTNSStringFromString` with 43 callers, and it does not add an allocation: `stringWithCString:` and `initWithBytes:` both allocate an NSString. Keeping the `NSData` form deliberately, because the cheaper alternative relies on `UTF8String`'s buffer containing embedded NULs, which is exactly the ambiguity this diff removes. `arc lint -e extra` on the test file reports only pre-existing warnings plus a NULLSAFECLANG infrastructure failure. Reviewed By: cipolleschi Differential Revision: D115707536 Pulled By: fabriziocucci fbshipit-source-id: 4cde8d4b584e0305fcfc3a4520f3690b411500cb
1 parent 93284f5 commit 5906cfb

6 files changed

Lines changed: 87 additions & 13 deletions

File tree

packages/react-native/React/Fabric/RCTConversions.h

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ inline NSString *RCTNSStringFromString(
3434
const std::string &string,
3535
const NSStringEncoding &encoding = NSUTF8StringEncoding)
3636
{
37-
return [NSString stringWithCString:string.c_str() encoding:encoding] ?: @"";
37+
NSString *result = [[NSString alloc] initWithBytes:string.data() length:string.size() encoding:encoding];
38+
return result != nil ? result : @"";
3839
}
3940

4041
inline NSString *_Nullable RCTNSStringFromStringNilIfEmpty(
@@ -46,7 +47,14 @@ inline NSString *_Nullable RCTNSStringFromStringNilIfEmpty(
4647

4748
inline std::string RCTStringFromNSString(NSString *string)
4849
{
49-
return std::string{string.UTF8String ?: ""};
50+
if (string == nil) {
51+
return "";
52+
}
53+
NSData *data = [string dataUsingEncoding:NSUTF8StringEncoding];
54+
if (data == nil) {
55+
return "";
56+
}
57+
return std::string{static_cast<const char *>(data.bytes), data.length};
5058
}
5159

5260
inline UIColor *_Nullable RCTUIColorFromSharedColor(const facebook::react::SharedColor &sharedColor)

packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
#import <Foundation/Foundation.h>
1010
#import <XCTest/XCTest.h>
1111

12+
#import <React/RCTConversions.h>
1213
#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
1314
#import <react/renderer/textlayoutmanager/RCTFontUtils.h>
1415

1516
#include <react/renderer/attributedstring/conversions.h>
1617
#include <react/renderer/core/RawValue.h>
1718

19+
#include <array>
1820
#include <utility>
1921

2022
using namespace facebook::react;
@@ -384,4 +386,56 @@ - (void)testDifferenceOfMissingParagraphStyle
384386
attributedString2, attributedString1, insensitiveAttributes, textAttributes));
385387
}
386388

389+
// A std::string may legitimately contain an embedded NUL. The C-string APIs
390+
// these converters used to call stop at it, silently truncating user text.
391+
392+
- (void)testNSStringFromStringPreservesEmbeddedNull
393+
{
394+
std::string withNull("Hello\0World", 11);
395+
XCTAssertEqual(withNull.size(), 11u);
396+
397+
NSString *converted = RCTNSStringFromString(withNull);
398+
399+
XCTAssertEqual(converted.length, 11u);
400+
XCTAssertTrue([converted hasPrefix:@"Hello"]);
401+
XCTAssertTrue([converted hasSuffix:@"World"]);
402+
}
403+
404+
- (void)testStringFromNSStringPreservesEmbeddedNull
405+
{
406+
std::array<unichar, 3> chars{'a', 0, 'b'};
407+
NSString *withNull = [NSString stringWithCharacters:chars.data() length:chars.size()];
408+
XCTAssertEqual(withNull.length, 3u);
409+
410+
std::string converted = RCTStringFromNSString(withNull);
411+
412+
XCTAssertEqual(converted.size(), 3u);
413+
XCTAssertEqual(converted[0], 'a');
414+
XCTAssertEqual(converted[1], '\0');
415+
XCTAssertEqual(converted[2], 'b');
416+
}
417+
418+
- (void)testStringConversionRoundTripsEmbeddedNull
419+
{
420+
std::string original("A\0B\0C", 5);
421+
422+
std::string roundTripped = RCTStringFromNSString(RCTNSStringFromString(original));
423+
424+
XCTAssertEqual(roundTripped.size(), original.size());
425+
XCTAssertTrue(roundTripped == original);
426+
}
427+
428+
- (void)testAttributedStringFromFragmentPreservesEmbeddedNull
429+
{
430+
AttributedString attributedString;
431+
AttributedString::Fragment fragment;
432+
fragment.string = std::string("Hello\0World", 11);
433+
attributedString.appendFragment(std::move(fragment));
434+
435+
NSAttributedString *result = RCTNSAttributedStringFromAttributedString(attributedString);
436+
437+
XCTAssertEqual(result.string.length, 11u);
438+
XCTAssertTrue([result.string hasSuffix:@"World"]);
439+
}
440+
387441
@end

packages/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,10 @@ JNIArgs convertJSIArgsToJNIArgs(
406406
throw JavaTurboModuleArgumentConversionException(
407407
"string", argIndex, methodName, arg, &rt);
408408
}
409-
jarg->l = makeGlobalIfNecessary(
410-
env->NewStringUTF(arg->getString(rt).utf8(rt).c_str()));
409+
auto utf16 = rt.utf16(arg->getString(rt));
410+
jarg->l = makeGlobalIfNecessary(env->NewString(
411+
reinterpret_cast<const jchar*>(utf16.data()),
412+
static_cast<jsize>(utf16.size())));
411413
} else if (type == "Lcom/facebook/react/bridge/Callback;") {
412414
if (!(arg->isObject() && arg->getObject(rt).isFunction(rt))) {
413415
throw JavaTurboModuleArgumentConversionException(
@@ -785,11 +787,14 @@ jsi::Value JavaTurboModule::invokeJavaMethod(
785787

786788
jsi::Value returnValue = jsi::Value::null();
787789
if (returnString != nullptr) {
788-
const char* js = env->GetStringUTFChars(returnString, nullptr);
789-
std::string result = js;
790-
env->ReleaseStringUTFChars(returnString, js);
791-
returnValue =
792-
jsi::Value(runtime, jsi::String::createFromUtf8(runtime, result));
790+
jsize length = env->GetStringLength(returnString);
791+
const jchar* chars = env->GetStringChars(returnString, nullptr);
792+
auto jsiString = jsi::String::createFromUtf16(
793+
runtime,
794+
reinterpret_cast<const char16_t*>(chars),
795+
static_cast<size_t>(length));
796+
env->ReleaseStringChars(returnString, chars);
797+
returnValue = jsi::Value(runtime, jsiString);
793798
}
794799

795800
TMPL::syncMethodCallReturnConversionEnd(moduleName, methodName);

packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ size_t size() const override
143143

144144
static NSString *convertJSIStringToNSString(jsi::Runtime &runtime, const jsi::String &value)
145145
{
146-
return [NSString stringWithUTF8String:value.utf8(runtime).c_str()];
146+
auto utf8 = value.utf8(runtime);
147+
NSString *result = [[NSString alloc] initWithBytes:utf8.data() length:utf8.size() encoding:NSUTF8StringEncoding];
148+
return result != nil ? result : @"";
147149
}
148150

149151
static NSArray *convertJSIArrayToNSArray(

packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,10 @@ void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText)
417417

418418
return [[NSMutableAttributedString attributedStringWithAttachment:attachment] mutableCopy];
419419
} else {
420-
NSString *string = [NSString stringWithUTF8String:fragment.string.c_str()];
420+
NSString *decoded = [[NSString alloc] initWithBytes:fragment.string.data()
421+
length:fragment.string.size()
422+
encoding:NSUTF8StringEncoding];
423+
NSString *string = decoded != nil ? decoded : @"";
421424

422425
if (fragment.textAttributes.textTransform.has_value()) {
423426
auto textTransform = fragment.textAttributes.textTransform.value();

packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,11 @@ - (LinesMeasurements)getLinesForAttributedString:(facebook::react::AttributedStr
400400
.width = usedRect.size.width, .height = usedRect.size.height}};
401401

402402
CGFloat baseline = [layoutManager locationForGlyphAtIndex:range.location].y;
403-
const char *renderedUTF8 = [renderedString UTF8String];
403+
NSData *renderedData = [renderedString dataUsingEncoding:NSUTF8StringEncoding];
404404
auto line = LineMeasurement{
405-
std::string(renderedUTF8 != nullptr ? renderedUTF8 : ""),
405+
std::string(
406+
renderedData != nil ? static_cast<const char *>(renderedData.bytes) : "",
407+
renderedData != nil ? renderedData.length : 0),
406408
rect,
407409
overallRect.size.height - baseline,
408410
font.capHeight,

0 commit comments

Comments
 (0)