This is an automated email from the ASF dual-hosted git repository.

markt-asf pushed a commit to branch 9.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git

commit bef4ed7702f27f2355163f5671e2107bdf6f2a9f
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 fb78c32910..ef3771a33f 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 31606421e1..15ce97cb89 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 f5c0141770..641c970b8f 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -343,6 +343,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]

Reply via email to