Fix corruption of supplementary characters in AppendableByteArray

A high surrogate left unconsumed by the encoder was dropped between
append calls, so surrogate pairs written a character at a time by
JsonValueWriter were replaced with `?`.

See gh-51464

Signed-off-by: JaeHyunAn <98042706+yyuneu@users.noreply.github.com>
This commit is contained in:
JaeHyunAn
2026-09-04 13:43:50 +01:00
committed by Andy Wilkinson
parent 61a62d56d8
commit 87cadf2c9c
3 changed files with 49 additions and 3 deletions
@@ -56,6 +56,8 @@ class AppendableByteArray implements Appendable {
private ByteBuffer out; private ByteBuffer out;
private char highSurrogate;
AppendableByteArray(Charset charset) { AppendableByteArray(Charset charset) {
this(charset, DEFAULT_INITIAL_SIZE, DEFAULT_EXPANSION_SIZE); this(charset, DEFAULT_INITIAL_SIZE, DEFAULT_EXPANSION_SIZE);
} }
@@ -89,8 +91,19 @@ class AppendableByteArray implements Appendable {
} }
private AppendableByteArray append(CharBuffer in) throws IOException { private AppendableByteArray append(CharBuffer in) throws IOException {
CoderResult result = this.encoder.encode(in, this.out, false); if (this.highSurrogate != 0) {
CharBuffer pending = CharBuffer.allocate(in.remaining() + 1);
pending.put(this.highSurrogate).put(in).flip();
this.highSurrogate = 0;
in = pending;
}
return append(in, false);
}
private AppendableByteArray append(CharBuffer in, boolean endOfInput) throws IOException {
CoderResult result = this.encoder.encode(in, this.out, endOfInput);
if (result.isUnderflow()) { if (result.isUnderflow()) {
this.highSurrogate = (in.hasRemaining()) ? in.get() : 0;
return this; return this;
} }
if (result.isOverflow()) { if (result.isOverflow()) {
@@ -98,13 +111,18 @@ class AppendableByteArray implements Appendable {
this.out = ByteBuffer.allocate(out.capacity() + this.expansionSize); this.out = ByteBuffer.allocate(out.capacity() + this.expansionSize);
out.flip(); out.flip();
this.out.put(out); this.out.put(out);
return append(in); return append(in, endOfInput);
} }
result.throwException(); result.throwException();
return this; return this;
} }
byte[] toByteArray() { byte[] toByteArray() throws IOException {
if (this.highSurrogate != 0) {
CharBuffer in = CharBuffer.wrap(new char[] { this.highSurrogate });
this.highSurrogate = 0;
append(in, true);
}
this.out.flip(); this.out.flip();
int limit = this.out.limit(); int limit = this.out.limit();
int position = this.out.position(); int position = this.out.position();
@@ -120,6 +138,7 @@ class AppendableByteArray implements Appendable {
private void reset() { private void reset() {
this.out.clear(); this.out.clear();
this.encoder.reset(); this.encoder.reset();
this.highSurrogate = 0;
} }
static byte[] toByteArray(Charset charset, ThrowingConsumer<Appendable> appendable) throws IOException { static byte[] toByteArray(Charset charset, ThrowingConsumer<Appendable> appendable) throws IOException {
@@ -90,6 +90,22 @@ class AppendableByteArrayTests {
assertThat(reused).isEqualTo("clean".getBytes(StandardCharsets.UTF_8)); assertThat(reused).isEqualTo("clean".getBytes(StandardCharsets.UTF_8));
} }
@Test
void writesSurrogatePairAppendedAsIndividualChars() throws Exception {
assertByteArray(StandardCharsets.UTF_8, (appendable) -> appendable.append('\uD83D').append('\uDE00'));
assertByteArray(StandardCharsets.UTF_16, (appendable) -> appendable.append('\uD83D').append('\uDE00'));
}
@Test
void writesSurrogatePairSplitAcrossAppendedStrings() throws Exception {
assertByteArray(StandardCharsets.UTF_8, (appendable) -> appendable.append("a\uD83D").append("\uDE00b"));
}
@Test
void writesUnpairedHighSurrogate() throws Exception {
assertByteArray(StandardCharsets.UTF_8, (appendable) -> appendable.append('\uD83D'));
}
private void assertByteArray(Charset charset, ThrowingConsumer<Appendable> action) throws Exception { private void assertByteArray(Charset charset, ThrowingConsumer<Appendable> action) throws Exception {
assertByteArray(4, 4, charset, action); assertByteArray(4, 4, charset, action);
} }
@@ -66,6 +66,17 @@ class WritableJsonTests {
assertThat(writable.toByteArray()).isEqualTo("{}".getBytes()); assertThat(writable.toByteArray()).isEqualTo("{}".getBytes());
} }
@Test
void toByteArrayWhenContentIsAppendedCharByCharWritesSupplementaryCharacters() {
String emoji = new String(Character.toChars(0x1F600));
WritableJson writable = (out) -> {
for (int i = 0; i < emoji.length(); i++) {
out.append(emoji.charAt(i));
}
};
assertThat(writable.toByteArray(StandardCharsets.UTF_8)).isEqualTo(emoji.getBytes(StandardCharsets.UTF_8));
}
@Test @Test
void toResourceWritesJson() throws Exception { void toResourceWritesJson() throws Exception {
File file = new File(this.temp, "out.json"); File file = new File(this.temp, "out.json");