This is an automated email from the ASF dual-hosted git repository.
markt-asf pushed a commit to branch 11.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/11.0.x by this push:
new 4148dd19b8 Fix handling of per-message-deflate context takeover for
receive
4148dd19b8 is described below
commit 4148dd19b858f492d67bd583a2c794f83a835fbb
Author: Mark Thomas <[email protected]>
AuthorDate: Tue Sep 8 20:59:23 2026 +0100
Fix handling of per-message-deflate context takeover for receive
---
.../apache/tomcat/websocket/PerMessageDeflate.java | 104 ++++-
.../tomcat/websocket/TestPerMessageDeflate.java | 422 ++++++++++++++++++++-
webapps/docs/changelog.xml | 4 +
3 files changed, 500 insertions(+), 30 deletions(-)
diff --git a/java/org/apache/tomcat/websocket/PerMessageDeflate.java
b/java/org/apache/tomcat/websocket/PerMessageDeflate.java
index 502b5d0b91..b5bf3df5ee 100644
--- a/java/org/apache/tomcat/websocket/PerMessageDeflate.java
+++ b/java/org/apache/tomcat/websocket/PerMessageDeflate.java
@@ -72,6 +72,23 @@ public class PerMessageDeflate implements Transformation {
private final Deflater deflater = new
Deflater(Deflater.DEFAULT_COMPRESSION, true);
private final byte[] EOM_BUFFER = new byte[EOM_BYTES.length + 1];
+ /*
+ * Whether the LZ77 window used to decompress incoming messages persists
across message boundaries. This side's
+ * inflater decompresses whatever the *peer* compressed, so it is governed
by the peer's context takeover
+ * setting: a server's inflater follows clientContextTakeover and a
client's inflater follows serverContextTakeover.
+ */
+ private final boolean inflaterContextTakeover;
+ /*
+ * Rolling copy of the last up to inflaterWindow.length bytes of output
produced by inflater, maintained for every
+ * message regardless of inflaterContextTakeover (see the constructor).
Needed because resetting the Inflater -
+ * either mid-message to recover from an early BFINAL block, or in
endFrame() to clear a
+ * finished-but-not-really-done state - discards its LZ77 window; feeding
this back via setDictionary() immediately
+ * after such a reset lets back-references into content compressed before
the reset keep resolving correctly.
+ * endFrame() clears inflaterWindowLength at the end of a message when
inflaterContextTakeover is false, so the
+ * window never actually survives *across* messages in that case - only
within one.
+ */
+ private final byte[] inflaterWindow;
+
private volatile Transformation next;
private volatile boolean skipDecompression = false;
private volatile boolean eomBytesInserted = false;
@@ -86,6 +103,8 @@ public class PerMessageDeflate implements Transformation {
*/
private volatile int lastInputOffset;
private volatile int lastInputLength;
+ // Number of valid bytes currently held in inflaterWindow.
+ private volatile int inflaterWindowLength;
private volatile ByteBuffer writeBuffer =
ByteBuffer.allocate(Constants.DEFAULT_BUFFER_SIZE);
private volatile boolean firstCompressedFrameWritten = false;
// Flag to track if a message is completely empty
@@ -203,6 +222,19 @@ public class PerMessageDeflate implements Transformation {
this.clientContextTakeover = clientContextTakeover;
this.clientMaxWindowBits = clientMaxWindowBits;
this.isServer = isServer;
+ this.inflaterContextTakeover = isServer ? clientContextTakeover :
serverContextTakeover;
+ /*
+ * 32768 (2^15) is the maximum DEFLATE window size and the one
java.util.zip.Inflater/Deflater always
+ * effectively use; there is no way, via the public Java SE API, to
honour a smaller negotiated max_window_bits
+ * value here.
+ *
+ * Always allocated, even when inflaterContextTakeover is false: that
setting only governs whether the window
+ * survives *between* messages (see endFrame()). RFC 7692 section
7.2.1 permits a single message to be
+ * compressed as multiple DEFLATE blocks, and decompressing that
correctly requires window continuity *within*
+ * the message (see the mid-message recovery in getMoreData())
regardless of the cross-message context takeover
+ * setting.
+ */
+ this.inflaterWindow = new byte[32768];
}
@@ -300,7 +332,16 @@ public class PerMessageDeflate implements Transformation {
int newOffset = lastInputOffset + lastInputLength -
remaining;
try {
inflater.reset();
- } catch (NullPointerException e) {
+ // reset() discards the LZ77 window along with the
+ // finished state. If context takeover means that
+ // window should have survived, restore it so
+ // back-references into content decompressed before
+ // this reset keep resolving correctly.
+ if (inflaterWindowLength > 0) {
+ inflater.setDictionary(inflaterWindow, 0,
inflaterWindowLength);
+ }
+ } catch (IllegalStateException | NullPointerException e) {
+ // As of Java 25, the JRE throws an ISE rather than an
NPE
throw new
IOException(sm.getString("perMessageDeflate.alreadyClosed"), e);
}
inflater.setInput(readBuffer.array(), newOffset,
remaining);
@@ -342,39 +383,76 @@ public class PerMessageDeflate implements Transformation {
private int inflate(byte[] dest, int start, int len) throws IOException {
+ int written;
try {
- return inflater.inflate(dest, start, len);
+ written = inflater.inflate(dest, start, len);
} catch (DataFormatException e) {
throw new
IOException(sm.getString("perMessageDeflate.deflateFailed"), e);
} catch (IllegalStateException | NullPointerException e) {
// As of Java 25, the JRE throws an ISE rather than an NPE
throw new
IOException(sm.getString("perMessageDeflate.alreadyClosed"), e);
}
+ if (written > 0) {
+ updateInflaterWindow(dest, start, written);
+ }
+ return written;
}
+
+ /*
+ * Keeps inflaterWindow holding a rolling copy of the last up to
inflaterWindow.length bytes of decompressed
+ * output, across however many inflate() calls and messages that takes -
tracked unconditionally, regardless of
+ * inflaterContextTakeover (see the constructor and endFrame(), which is
where that setting actually takes
+ * effect, by clearing inflaterWindowLength at the end of a message when
it is false). Called for every
+ * successful inflate() (including the single-byte EOM overflow case), so
it is the one place that needs to know
+ * about that.
+ */
+ private void updateInflaterWindow(byte[] src, int off, int len) {
+ if (len >= inflaterWindow.length) {
+ System.arraycopy(src, off + len - inflaterWindow.length,
inflaterWindow, 0, inflaterWindow.length);
+ inflaterWindowLength = inflaterWindow.length;
+ } else {
+ int keep = Math.min(inflaterWindowLength, inflaterWindow.length -
len);
+ System.arraycopy(inflaterWindow, inflaterWindowLength - keep,
inflaterWindow, 0, keep);
+ System.arraycopy(src, off, inflaterWindow, keep, len);
+ inflaterWindowLength = keep + len;
+ }
+ }
+
+
private TransformationResult endFrame(boolean fin) throws IOException {
eomBytesInserted = false;
eomOverflowWritten = false;
if (fin) {
- boolean contextTakeover = isServer ? clientContextTakeover :
serverContextTakeover;
/*
- * If the message's final block was itself an independently
BFINAL=1 terminated block (see the recovery
- * in getMoreData()), the EOM_BYTES appended to complete the
message per RFC 7692 section 7.2.2 were fed
- * to an already-finished Inflater and were never consumed:
inflater.finished() stays true with those 4
- * bytes still reported by getRemaining(). Left in that state, the
next message would compute its first
- * recovery offset from this stale, unrelated leftover count,
which can go negative. There is no way to
- * continue decompressing past a finished Inflater in place, so it
has to be reset here too - even though
- * context takeover is enabled - at the cost of losing the window
for the *next* message in this,
- * otherwise rare, case.
+ * If the message's final block was itself an independently
BFINAL=1 terminated block (see the recovery in
+ * getMoreData()), the EOM_BYTES appended to complete the message
per RFC 7692 section 7.2.2 were fed to an
+ * already-finished Inflater and were never consumed:
inflater.finished() stays true with those 4 bytes
+ * still reported by getRemaining(). Left in that state, the next
message would compute its first recovery
+ * offset from this stale, unrelated leftover count, which can go
negative. There is no way to continue
+ * decompressing past a finished Inflater in place, so it has to
be reset here too - even though context
+ * takeover may be enabled. Unlike the no-context-takeover case,
the window built up so far is still wanted
+ * for the next message, so restore it via setDictionary() rather
than losing it.
*/
- if (!contextTakeover || inflater.finished()) {
+ if (!inflaterContextTakeover || inflater.finished()) {
try {
inflater.reset();
- } catch (NullPointerException e) {
+ if (inflaterContextTakeover && inflaterWindowLength > 0) {
+ inflater.setDictionary(inflaterWindow, 0,
inflaterWindowLength);
+ }
+ } catch (IllegalStateException | NullPointerException e) {
+ // As of Java 25, the JRE throws an ISE rather than an NPE
throw new
IOException(sm.getString("perMessageDeflate.alreadyClosed"), e);
}
lastInputOffset = 0;
lastInputLength = 0;
+ if (!inflaterContextTakeover) {
+ /*
+ * The window was still legitimately maintained *within*
this message (see inflate()), but must not
+ * survive into the next one when context takeover is
disabled.
+ */
+ inflaterWindowLength = 0;
+ }
}
}
return TransformationResult.END_OF_FRAME;
diff --git a/test/org/apache/tomcat/websocket/TestPerMessageDeflate.java
b/test/org/apache/tomcat/websocket/TestPerMessageDeflate.java
index 93a7ede4a3..1374652bc6 100644
--- a/test/org/apache/tomcat/websocket/TestPerMessageDeflate.java
+++ b/test/org/apache/tomcat/websocket/TestPerMessageDeflate.java
@@ -294,12 +294,12 @@ public class TestPerMessageDeflate {
/*
- * A message whose real content is a single, exactly-fitting,
independently BFINAL=1 terminated block (nothing
- * else) finishes cleanly via the normal needsInput()==true path and, as
part of that, has the RFC 7692 section
- * 7.2.2 EOM_BYTES fed to an already-finished Inflater - which silently
ignores them, leaving finished()==true
- * with those 4 bytes stuck in getRemaining(). With context takeover
enabled, endFrame() must not leave the
- * Inflater in that state: otherwise the next message's recovery logic
computes its first offset from this
- * stale, unrelated leftover count.
+ * A message whose real content is a single, exactly-fitting,
independently BFINAL=1 terminated block (nothing else)
+ * finishes cleanly via the normal needsInput()==true path and, as part of
that, has the RFC 7692 section 7.2.2
+ * EOM_BYTES fed to an already-finished Inflater - which silently ignores
them, leaving finished()==true with those
+ * 4 bytes stuck in getRemaining(). With context takeover enabled,
endFrame() must not leave the Inflater in that
+ * state: otherwise the next message's recovery logic computes its first
offset from this stale, unrelated leftover
+ * count.
*/
@Test
public void testMessageEndingInCleanBfinalBlockDoesNotPoisonNextMessage()
throws IOException {
@@ -311,12 +311,12 @@ public class TestPerMessageDeflate {
preferences.add(parameters);
/*
- * Context takeover is enabled by default (no *_no_context_takeover
parameter) - the same PerMessageDeflate
- * instance, and therefore the same Inflater, must be reused across
messages, exactly as it would be for a
- * real connection. setNext() on PerMessageDeflate delegates to the
existing next's setNext() once next is
- * already set, so a single mutable source (rather than two separate
TesterTransformation instances) is used
- * to supply both messages' bytes in turn.
- */
+ * Context takeover is enabled by default (no *_no_context_takeover
parameter) - the same PerMessageDeflate
+ * instance, and therefore the same Inflater, must be reused across
messages, exactly as it would be for a real
+ * connection. setNext() on PerMessageDeflate delegates to the
existing next's setNext() once next is already
+ * set, so a single mutable source (rather than two separate
TesterTransformation instances) is used to supply
+ * both messages' bytes in turn.
+ */
PerMessageDeflate perMessageDeflateRx =
PerMessageDeflate.build(preferences, true);
MutableTesterTransformation source = new
MutableTesterTransformation(ByteBuffer.wrap(compressed1));
perMessageDeflateRx.setNext(source);
@@ -346,15 +346,403 @@ public class TestPerMessageDeflate {
received2.write(buf.array(), 0, buf.position());
} while (tr == TransformationResult.OVERFLOW);
Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
- Assert.assertArrayEquals("Second message must decompress correctly;
the first message's clean BFINAL "
- + "ending must not poison the shared Inflater's state",
message2, received2.toByteArray());
+ Assert.assertArrayEquals("Second message must decompress correctly;
the first message's clean BFINAL " +
+ "ending must not poison the shared Inflater's state",
message2, received2.toByteArray());
+ }
+
+
+ /*
+ * The mid-message reset used by
testMultipleFinalDeflateBlocksInOneMessage to recover from an early BFINAL block
+ * discards the Inflater's LZ77 window. When context takeover is
negotiated (the default - no *_no_context_takeover
+ * parameter), that window must survive: a later block may rely on
back-references into content compressed before
+ * the reset. Verified for both directions: a server's inflater
decompresses whatever the client compressed, so it
+ * is governed by clientContextTakeover; a client's inflater is governed
by serverContextTakeover.
+ */
+ @Test
+ public void testContextTakeoverSurvivesEarlyBfinalBlockServer() throws
IOException {
+ testContextTakeoverSurvivesEarlyBfinalBlock(true);
+ }
+
+
+ @Test
+ public void testContextTakeoverSurvivesEarlyBfinalBlockClient() throws
IOException {
+ testContextTakeoverSurvivesEarlyBfinalBlock(false);
+ }
+
+
+ private void testContextTakeoverSurvivesEarlyBfinalBlock(boolean isServer)
throws IOException {
+ // part2 deliberately repeats a large chunk of part1 verbatim, so a
compressor with a live window naturally
+ // emits LZ77 back-references into part1's content when compressing
part2.
+ String repeatedChunk = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.
".repeat(50);
+ byte[] part1 = (repeatedChunk + "part1 unique
tail.").getBytes(StandardCharsets.UTF_8);
+ byte[] part2 = (repeatedChunk + "part2 unique tail, referencing the
same repeated chunk as part1.")
+ .getBytes(StandardCharsets.UTF_8);
+
+ byte[] compressed1 = rawDeflateFinished(part1);
+ byte[] dictionary =
+ part1.length > 32768 ? Arrays.copyOfRange(part1, part1.length
- 32768, part1.length) : part1;
+ byte[] compressed2 = rawDeflateFinished(part2, dictionary);
+
+ // Confirm the setup actually exercises cross-block back-references,
rather than merely asserting on a
+ // payload that would pass even without a shared window.
+ Assert.assertTrue("Test setup problem: part2 should compress smaller
when it can reference part1's window",
+ compressed2.length < rawDeflateFinished(part2, null).length);
+
+ // Two independently BFINAL=1-terminated blocks concatenated, exactly
as in
+ // testMultipleFinalDeflateBlocksInOneMessage - this is what forces
getMoreData() to reset() mid-message.
+ byte[] compressedPayload = concat(compressed1, compressed2);
+
+ List<Parameter> parameters = Collections.emptyList();
+ List<List<Parameter>> preferences = new ArrayList<>();
+ preferences.add(parameters);
+
+ PerMessageDeflate perMessageDeflateRx =
PerMessageDeflate.build(preferences, isServer);
+ perMessageDeflateRx.setNext(new
TesterTransformation(ByteBuffer.wrap(compressedPayload)));
+
+ int rsv = 0b100;
+
+ ByteArrayOutputStream received = new ByteArrayOutputStream();
+ ByteBuffer buf = ByteBuffer.allocate(8192);
+ TransformationResult tr;
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+
+ byte[] expected = concat(part1, part2);
+ Assert.assertArrayEquals(
+ "Expected the concatenation of both blocks' decompressed
content - part2's back-references into " +
+ "part1 must still resolve after the mid-message reset",
+ expected, received.toByteArray());
+ }
+
+
+ /*
+ * As testContextTakeoverSurvivesEarlyBfinalBlock, but with context
takeover *disabled* for the direction under
+ * test. RFC 7692's context takeover setting only governs whether the LZ77
window is reset *between* messages;
+ * within a single message, multiple DEFLATE blocks must still be
decompressible as one continuous stream regardless
+ * of that setting. A later block's back-references into an earlier block
of the *same* message must therefore still
+ * resolve after the mid-message reset even when context takeover is off.
+ */
+ @Test
+ public void testNoContextTakeoverStillSurvivesEarlyBfinalBlockServer()
throws IOException {
+ testNoContextTakeoverStillSurvivesEarlyBfinalBlock(true);
+ }
+
+
+ @Test
+ public void testNoContextTakeoverStillSurvivesEarlyBfinalBlockClient()
throws IOException {
+ testNoContextTakeoverStillSurvivesEarlyBfinalBlock(false);
+ }
+
+
+ private void testNoContextTakeoverStillSurvivesEarlyBfinalBlock(boolean
isServer) throws IOException {
+ String repeatedChunk = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.
".repeat(50);
+ byte[] part1 = (repeatedChunk + "part1 unique
tail.").getBytes(StandardCharsets.UTF_8);
+ byte[] part2 = (repeatedChunk + "part2 unique tail, referencing the
same repeated chunk as part1.")
+ .getBytes(StandardCharsets.UTF_8);
+
+ byte[] compressed1 = rawDeflateFinished(part1);
+ byte[] dictionary =
+ part1.length > 32768 ? Arrays.copyOfRange(part1, part1.length
- 32768, part1.length) : part1;
+ byte[] compressed2 = rawDeflateFinished(part2, dictionary);
+
+ Assert.assertTrue("Test setup problem: part2 should compress smaller
when it can reference part1's window",
+ compressed2.length < rawDeflateFinished(part2, null).length);
+
+ byte[] compressedPayload = concat(compressed1, compressed2);
+
+ // Disable context takeover for whichever direction governs this
instance's *receiving* window: a server's
+ // inflater is governed by the client's setting, and vice versa.
+ List<Parameter> parameters = new ArrayList<>();
+ parameters.add(
+ new WsExtensionParameter(isServer ?
"client_no_context_takeover" : "server_no_context_takeover", null));
+ List<List<Parameter>> preferences = new ArrayList<>();
+ preferences.add(parameters);
+
+ PerMessageDeflate perMessageDeflateRx =
PerMessageDeflate.build(preferences, isServer);
+ perMessageDeflateRx.setNext(new
TesterTransformation(ByteBuffer.wrap(compressedPayload)));
+
+ int rsv = 0b100;
+
+ ByteArrayOutputStream received = new ByteArrayOutputStream();
+ ByteBuffer buf = ByteBuffer.allocate(8192);
+ TransformationResult tr;
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+
+ byte[] expected = concat(part1, part2);
+ Assert.assertArrayEquals(
+ "Expected the concatenation of both blocks' decompressed
content - part2's back-references into " +
+ "part1 (same message) must resolve regardless of
context takeover being disabled",
+ expected, received.toByteArray());
+ }
+
+
+ /*
+ * Covers a sequence of two messages - an ordinary one followed by a
multi-block one - on the same connection with
+ * context takeover disabled: an unrelated first message must not prevent
a later message's own, legitimate
+ * mid-message recovery (see
testNoContextTakeoverStillSurvivesEarlyBfinalBlock) from working.
+ *
+ * Note: endFrame() clears inflaterWindowLength at the end of a message
when context takeover is disabled, so that
+ * no state is retained across messages, matching RFC 7692's intent for
that setting. That clearing could not be
+ * shown to be load-bearing for this specific test: LZ77 back-reference
distances resolve from the *end* of whatever
+ * dictionary is supplied, and this class's rolling window always keeps
the most recently produced bytes at that
+ * end, evicting older bytes first - so an earlier message's leaked bytes,
being strictly older than anything in a
+ * later message, can only be evicted ahead of it or sit as unused
padding; they cannot shift or corrupt resolution
+ * of the later message's own back-references. The clearing is still
correct and kept for specification fidelity (a
+ * "no context takeover" Inflater should not retain any state across the
message boundary), just not something this
+ * test can fail without.
+ */
+ @Test
+ public void testNoContextTakeoverClearsWindowBetweenMessagesServer()
throws IOException {
+ testNoContextTakeoverClearsWindowBetweenMessages(true);
+ }
+
+
+ @Test
+ public void testNoContextTakeoverClearsWindowBetweenMessagesClient()
throws IOException {
+ testNoContextTakeoverClearsWindowBetweenMessages(false);
+ }
+
+
+ private void testNoContextTakeoverClearsWindowBetweenMessages(boolean
isServer) throws IOException {
+ // message1: ordinary, single-block message, unrelated to message2 -
its content must not leak forward.
+ byte[] message1 = "Unrelated first message that must not leak into the
next message's window. ".repeat(20)
+ .getBytes(StandardCharsets.UTF_8);
+ byte[] compressed1 = rawDeflateFinished(message1);
+
+ // message2: two blocks, exactly as a compliant sender honouring
no_context_takeover would produce - part2b
+ // legitimately references only part2a, both from the *same* message.
+ String repeatedChunk = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.
".repeat(50);
+ byte[] part2a = (repeatedChunk + "message2 part
A.").getBytes(StandardCharsets.UTF_8);
+ byte[] part2b = (repeatedChunk + "message2 part B, referencing part
A's repeated chunk.")
+ .getBytes(StandardCharsets.UTF_8);
+ byte[] block2a = rawDeflateFinished(part2a);
+ byte[] dictionary =
+ part2a.length > 32768 ? Arrays.copyOfRange(part2a,
part2a.length - 32768, part2a.length) : part2a;
+ byte[] block2b = rawDeflateFinished(part2b, dictionary);
+ Assert.assertTrue("Test setup problem: part2b should compress smaller
when it can reference part2a's window",
+ block2b.length < rawDeflateFinished(part2b, null).length);
+ byte[] compressed2 = concat(block2a, block2b);
+
+ List<Parameter> parameters = new ArrayList<>();
+ parameters.add(
+ new WsExtensionParameter(isServer ?
"client_no_context_takeover" : "server_no_context_takeover", null));
+ List<List<Parameter>> preferences = new ArrayList<>();
+ preferences.add(parameters);
+
+ PerMessageDeflate perMessageDeflateRx =
PerMessageDeflate.build(preferences, isServer);
+ MutableTesterTransformation source = new
MutableTesterTransformation(ByteBuffer.wrap(compressed1));
+ perMessageDeflateRx.setNext(source);
+ int rsv = 0b100;
+
+ ByteArrayOutputStream received1 = new ByteArrayOutputStream();
+ ByteBuffer buf = ByteBuffer.allocate(8192);
+ TransformationResult tr;
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received1.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+ Assert.assertArrayEquals(message1, received1.toByteArray());
+
+ source.data = ByteBuffer.wrap(compressed2);
+ source.delivered = false;
+
+ ByteArrayOutputStream received2 = new ByteArrayOutputStream();
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received2.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+
+ byte[] expected2 = concat(part2a, part2b);
+ Assert.assertArrayEquals("message2's own mid-message recovery must use
a dictionary built only from " +
+ "message2's own content, not contaminated by message1's, which
must have been cleared at the " +
+ "message1/message2 boundary", expected2,
received2.toByteArray());
+ }
+
+
+ /*
+ * As testContextTakeoverSurvivesEarlyBfinalBlock, but exercising the
*other* reset point: endFrame()'s recovery
+ * from a message whose final block was an independently BFINAL=1
terminated block (see
+ * testMessageEndingInCleanBfinalBlockDoesNotPoisonNextMessage). That
reset must also preserve the context takeover
+ * window across the message boundary, not just avoid crashing.
+ */
+ @Test
+ public void testContextTakeoverSurvivesCleanBfinalMessageEndServer()
throws IOException {
+ testContextTakeoverSurvivesCleanBfinalMessageEnd(true);
+ }
+
+
+ @Test
+ public void testContextTakeoverSurvivesCleanBfinalMessageEndClient()
throws IOException {
+ testContextTakeoverSurvivesCleanBfinalMessageEnd(false);
+ }
+
+
+ private void testContextTakeoverSurvivesCleanBfinalMessageEnd(boolean
isServer) throws IOException {
+ String repeatedChunk = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.
".repeat(50);
+ byte[] message1 = (repeatedChunk + "message1 unique
tail.").getBytes(StandardCharsets.UTF_8);
+ byte[] message2 = (repeatedChunk + "message2 unique tail, referencing
the same repeated chunk as message1.")
+ .getBytes(StandardCharsets.UTF_8);
+
+ /*
+ * message1 is a single, exactly-fitting BFINAL=1 block - the shape
that leaves the Inflater finished() with the
+ * EOM bytes stuck in getRemaining() once endFrame() runs (see
+ * testMessageEndingInCleanBfinalBlockDoesNotPoisonNextMessage).
+ */
+ byte[] compressed1 = rawDeflateFinished(message1);
+ byte[] dictionary =
+ message1.length > 32768 ? Arrays.copyOfRange(message1,
message1.length - 32768, message1.length) :
+ message1;
+ byte[] compressed2 = rawDeflateFinished(message2, dictionary);
+
+ Assert.assertTrue(
+ "Test setup problem: message2 should compress smaller when it
can reference message1's " + "window",
+ compressed2.length < rawDeflateFinished(message2,
null).length);
+
+ List<Parameter> parameters = Collections.emptyList();
+ List<List<Parameter>> preferences = new ArrayList<>();
+ preferences.add(parameters);
+
+ PerMessageDeflate perMessageDeflateRx =
PerMessageDeflate.build(preferences, isServer);
+ MutableTesterTransformation source = new
MutableTesterTransformation(ByteBuffer.wrap(compressed1));
+ perMessageDeflateRx.setNext(source);
+ int rsv = 0b100;
+
+ ByteArrayOutputStream received1 = new ByteArrayOutputStream();
+ ByteBuffer buf = ByteBuffer.allocate(8192);
+ TransformationResult tr;
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received1.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+ Assert.assertArrayEquals(message1, received1.toByteArray());
+
+ source.data = ByteBuffer.wrap(compressed2);
+ source.delivered = false;
+
+ ByteArrayOutputStream received2 = new ByteArrayOutputStream();
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received2.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+ Assert.assertArrayEquals("message2's back-references into message1
must still resolve after endFrame()'s " +
+ "reset of the finished Inflater", message2,
received2.toByteArray());
+ }
+
+
+ /*
+ * Covers the *sending* side of context takeover, complementing the
receiving-side tests above: when reusing a
+ * single PerMessageDeflate instance to compress two messages, the
Deflater's own window must persist across the
+ * message boundary in both directions - a server's own sending is
governed by serverContextTakeover; a client's own
+ * sending is governed by clientContextTakeover.
+ */
+ @Test
+ public void testSendContextTakeoverServer() throws IOException {
+ testSendContextTakeover(true);
+ }
+
+
+ @Test
+ public void testSendContextTakeoverClient() throws IOException {
+ testSendContextTakeover(false);
+ }
+
+
+ private void testSendContextTakeover(boolean isServer) throws IOException {
+ String repeatedChunk = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.
".repeat(50);
+ byte[] message1 = (repeatedChunk + "message1 unique
tail.").getBytes(StandardCharsets.UTF_8);
+ byte[] message2 = (repeatedChunk + "message2 unique tail, referencing
the same repeated chunk as message1.")
+ .getBytes(StandardCharsets.UTF_8);
+
+ List<Parameter> parameters = Collections.emptyList();
+ List<List<Parameter>> preferences = new ArrayList<>();
+ preferences.add(parameters);
+
+ PerMessageDeflate perMessageDeflateTx =
PerMessageDeflate.build(preferences, isServer);
+ perMessageDeflateTx.setNext(new TesterTransformation());
+
+ List<MessagePart> uncompressedParts1 = new ArrayList<>();
+ uncompressedParts1.add(new MessagePart(true, 0,
Constants.OPCODE_BINARY, ByteBuffer.wrap(message1), null, null,
+ false, Long.MAX_VALUE));
+ MessagePart compressedPart1 =
perMessageDeflateTx.sendMessagePart(uncompressedParts1).get(0);
+
+ List<MessagePart> uncompressedParts2 = new ArrayList<>();
+ uncompressedParts2.add(new MessagePart(true, 0,
Constants.OPCODE_BINARY, ByteBuffer.wrap(message2), null, null,
+ false, Long.MAX_VALUE));
+ MessagePart compressedPart2 =
perMessageDeflateTx.sendMessagePart(uncompressedParts2).get(0);
+
+ // Proof that context takeover actually carried the window into the
second message's compression: it must
+ // be smaller than compressing the same content with no shared window
at all.
+ byte[] compressed2Standalone = rawDeflateFinished(message2, null);
+ Assert.assertTrue(
+ "message2 should compress smaller when the sending side's
context takeover window survives from " +
+ "message1: got " +
compressedPart2.getPayload().remaining() + " bytes, independently " +
+ "compressed would be at least " +
compressed2Standalone.length + " bytes",
+ compressedPart2.getPayload().remaining() <
compressed2Standalone.length);
+
+ // Round trip both messages through a matching receiving-side
instance, reusing it across both messages, to
+ // confirm the compressed output remains correctly decompressible.
+ PerMessageDeflate perMessageDeflateRx =
PerMessageDeflate.build(preferences, isServer);
+ MutableTesterTransformation source = new
MutableTesterTransformation(compressedPart1.getPayload());
+ perMessageDeflateRx.setNext(source);
+
+ ByteArrayOutputStream received1 = new ByteArrayOutputStream();
+ ByteBuffer buf = ByteBuffer.allocate(8192);
+ TransformationResult tr;
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(compressedPart1.getOpCode(),
compressedPart1.isFin(),
+ compressedPart1.getRsv(), buf);
+ received1.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+ Assert.assertArrayEquals(message1, received1.toByteArray());
+
+ source.data = compressedPart2.getPayload();
+ source.delivered = false;
+
+ ByteArrayOutputStream received2 = new ByteArrayOutputStream();
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(compressedPart2.getOpCode(),
compressedPart2.isFin(),
+ compressedPart2.getRsv(), buf);
+ received2.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+ Assert.assertArrayEquals(message2, received2.toByteArray());
}
private static byte[] rawDeflateFinished(byte[] data) {
+ return rawDeflateFinished(data, null);
+ }
+
+
+ private static byte[] rawDeflateFinished(byte[] data, byte[] dictionary) {
@SuppressWarnings("resource") // False positive
Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true);
try {
+ if (dictionary != null) {
+ deflater.setDictionary(dictionary);
+ }
deflater.setInput(data);
deflater.finish();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -457,9 +845,9 @@ public class TestPerMessageDeflate {
/*
- * Like TesterTransformation, but the source ByteBuffer can be swapped out
between messages, to exercise reuse of
- * a single PerMessageDeflate instance (and therefore its Inflater) across
multiple messages, as happens on a
- * real connection with context takeover enabled.
+ * Like TesterTransformation, but the source ByteBuffer can be swapped out
between messages, to exercise reuse of a
+ * single PerMessageDeflate instance (and therefore its Inflater) across
multiple messages, as happens on a real
+ * connection with context takeover enabled.
*/
private static class MutableTesterTransformation implements Transformation
{
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index fd28d64963..32019ec6ae 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -335,6 +335,10 @@
per-message-deflate that have one or more non-final blocks where the
<code>BFINAL</code> bit is set. (markt)
</fix>
+ <fix>
+ Fix handling of per-message-deflate context takeover when receiving
+ compressed WebSocket messages. (markt)
+ </fix>
</changelog>
</subsection>
<subsection name="Web applications">
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]