View Javadoc

1   //
2   //  ========================================================================
3   //  Copyright (c) 1995-2014 Mort Bay Consulting Pty. Ltd.
4   //  ------------------------------------------------------------------------
5   //  All rights reserved. This program and the accompanying materials
6   //  are made available under the terms of the Eclipse Public License v1.0
7   //  and Apache License v2.0 which accompanies this distribution.
8   //
9   //      The Eclipse Public License is available at
10  //      http://www.eclipse.org/legal/epl-v10.html
11  //
12  //      The Apache License v2.0 is available at
13  //      http://www.opensource.org/licenses/apache2.0.php
14  //
15  //  You may elect to redistribute this code under either of these licenses.
16  //  ========================================================================
17  //
18  
19  package org.eclipse.jetty.websocket.common.extensions.compress;
20  
21  import java.nio.ByteBuffer;
22  import java.util.zip.DataFormatException;
23  import java.util.zip.Deflater;
24  import java.util.zip.Inflater;
25  
26  import org.eclipse.jetty.util.BufferUtil;
27  import org.eclipse.jetty.util.log.Log;
28  import org.eclipse.jetty.util.log.Logger;
29  import org.eclipse.jetty.websocket.api.BadPayloadException;
30  import org.eclipse.jetty.websocket.api.WriteCallback;
31  import org.eclipse.jetty.websocket.api.extensions.ExtensionConfig;
32  import org.eclipse.jetty.websocket.api.extensions.Frame;
33  import org.eclipse.jetty.websocket.common.OpCode;
34  import org.eclipse.jetty.websocket.common.extensions.AbstractExtension;
35  import org.eclipse.jetty.websocket.common.frames.DataFrame;
36  
37  /**
38   * Per Message Deflate Compression extension for WebSocket.
39   * <p>
40   * Attempts to follow <a href="https://tools.ietf.org/html/draft-ietf-hybi-permessage-compression-12">draft-ietf-hybi-permessage-compression-12</a>
41   */
42  public class PerMessageDeflateExtension extends AbstractExtension
43  {
44      private static final boolean BFINAL_HACK = Boolean.parseBoolean(System.getProperty("jetty.websocket.bfinal.hack","true"));
45      private static final Logger LOG = Log.getLogger(PerMessageDeflateExtension.class);
46  
47      private static final int OVERHEAD = 64;
48      /** Tail Bytes per Spec */
49      private static final byte[] TAIL = new byte[]
50      { 0x00, 0x00, (byte)0xFF, (byte)0xFF };
51      private ExtensionConfig configRequested;
52      private ExtensionConfig configNegotiated;
53      private Deflater compressor;
54      private Inflater decompressor;
55  
56      private boolean incomingCompressed = false;
57      private boolean outgoingCompressed = false;
58      /**
59       * Context Takeover Control.
60       * <p>
61       * If true, the same LZ77 window is used between messages. Can be overridden with extension parameters.
62       */
63      private boolean incomingContextTakeover = true;
64      private boolean outgoingContextTakeover = true;
65  
66      @Override
67      public String getName()
68      {
69          return "permessage-deflate";
70      }
71  
72      @Override
73      public synchronized void incomingFrame(Frame frame)
74      {
75          switch (frame.getOpCode())
76          {
77              case OpCode.BINARY: // fall-thru
78              case OpCode.TEXT:
79                  incomingCompressed = frame.isRsv1();
80                  break;
81              case OpCode.CONTINUATION:
82                  if (!incomingCompressed)
83                  {
84                      nextIncomingFrame(frame);
85                  }
86                  break;
87              default:
88                  // All others are assumed to be control frames
89                  nextIncomingFrame(frame);
90                  return;
91          }
92  
93          if (!incomingCompressed || !frame.hasPayload())
94          {
95              // nothing to do with this frame
96              nextIncomingFrame(frame);
97              return;
98          }
99  
100         // Prime the decompressor
101         ByteBuffer payload = frame.getPayload();
102         int inlen = payload.remaining();
103         byte compressed[] = null;
104 
105         if (frame.isFin())
106         {
107             compressed = new byte[inlen + TAIL.length];
108             payload.get(compressed,0,inlen);
109             System.arraycopy(TAIL,0,compressed,inlen,TAIL.length);
110             incomingCompressed = false;
111         }
112         else
113         {
114             compressed = new byte[inlen];
115             payload.get(compressed,0,inlen);
116         }
117 
118         decompressor.setInput(compressed,0,compressed.length);
119 
120         // Since we don't track text vs binary vs continuation state, just grab whatever is the greater value.
121         int maxSize = Math.max(getPolicy().getMaxTextMessageSize(),getPolicy().getMaxBinaryMessageBufferSize());
122         ByteAccumulator accumulator = new ByteAccumulator(maxSize);
123 
124         DataFrame out = new DataFrame(frame);
125         out.setRsv1(false); // Unset RSV1
126 
127         // Perform decompression
128         while (decompressor.getRemaining() > 0 && !decompressor.finished())
129         {
130             byte outbuf[] = new byte[inlen];
131             try
132             {
133                 int len = decompressor.inflate(outbuf);
134                 if (len == 0)
135                 {
136                     if (decompressor.needsInput())
137                     {
138                         throw new BadPayloadException("Unable to inflate frame, not enough input on frame");
139                     }
140                     if (decompressor.needsDictionary())
141                     {
142                         throw new BadPayloadException("Unable to inflate frame, frame erroneously says it needs a dictionary");
143                     }
144                 }
145                 if (len > 0)
146                 {
147                     accumulator.addBuffer(outbuf,0,len);
148                 }
149             }
150             catch (DataFormatException e)
151             {
152                 LOG.warn(e);
153                 throw new BadPayloadException(e);
154             }
155         }
156 
157         // Forward on the frame
158         out.setPayload(accumulator.getByteBuffer(getBufferPool()));
159         nextIncomingFrame(out);
160     }
161 
162     /**
163      * Indicates use of RSV1 flag for indicating deflation is in use.
164      */
165     @Override
166     public boolean isRsv1User()
167     {
168         return true;
169     }
170 
171     @Override
172     public synchronized void outgoingFrame(Frame frame, WriteCallback callback)
173     {
174         if (OpCode.isControlFrame(frame.getOpCode()))
175         {
176             // skip, cannot compress control frames.
177             nextOutgoingFrame(frame,callback);
178             return;
179         }
180 
181         if (!frame.hasPayload())
182         {
183             // pass through, nothing to do
184             nextOutgoingFrame(frame,callback);
185             return;
186         }
187 
188         if (LOG.isDebugEnabled())
189         {
190             LOG.debug("outgoingFrame({}, {}) - {}",OpCode.name(frame.getOpCode()),callback != null?callback.getClass().getSimpleName():"<null>",
191                     BufferUtil.toDetailString(frame.getPayload()));
192         }
193 
194         // Prime the compressor
195         byte uncompressed[] = BufferUtil.toArray(frame.getPayload());
196 
197         // Perform the compression
198         if (!compressor.finished())
199         {
200             compressor.setInput(uncompressed,0,uncompressed.length);
201             byte compressed[] = new byte[uncompressed.length + OVERHEAD];
202 
203             while (!compressor.needsInput())
204             {
205                 int len = compressor.deflate(compressed,0,compressed.length,Deflater.SYNC_FLUSH);
206                 ByteBuffer outbuf = getBufferPool().acquire(len,true);
207                 BufferUtil.clearToFill(outbuf);
208 
209                 if (len > 0)
210                 {
211                     if (len > 4)
212                     {
213                         // Test for the 4 tail octets (0x00 0x00 0xff 0xff)
214                         int idx = len - 4;
215                         boolean found = true;
216                         for (int n = 0; n < TAIL.length; n++)
217                         {
218                             if (compressed[idx + n] != TAIL[n])
219                             {
220                                 found = false;
221                                 break;
222                             }
223                         }
224                         if (found)
225                         {
226                             len = len - 4;
227                         }
228                     }
229                     outbuf.put(compressed,0,len);
230                 }
231 
232                 BufferUtil.flipToFlush(outbuf,0);
233 
234                 if (len > 0 && BFINAL_HACK)
235                 {
236                     /*
237                      * Per the spec, it says that BFINAL 1 or 0 are allowed.
238                      * 
239                      * However, Java always uses BFINAL 1, whereas the browsers Chromium and Safari fail to decompress when it encounters BFINAL 1.
240                      * 
241                      * This hack will always set BFINAL 0
242                      */
243                     byte b0 = outbuf.get(0);
244                     if ((b0 & 1) != 0) // if BFINAL 1
245                     {
246                         outbuf.put(0,(b0 ^= 1)); // flip bit to BFINAL 0
247                     }
248                 }
249 
250                 DataFrame out = new DataFrame(frame,outgoingCompressed);
251                 out.setRsv1(true);
252                 out.setBufferPool(getBufferPool());
253                 out.setPayload(outbuf);
254 
255                 if (!compressor.needsInput())
256                 {
257                     // this is fragmented
258                     out.setFin(false);
259                     nextOutgoingFrame(out,null); // non final frames have no callback
260                 }
261                 else
262                 {
263                     // pass through the callback
264                     nextOutgoingFrame(out,callback);
265                 }
266 
267                 outgoingCompressed = !out.isFin();
268             }
269         }
270     }
271 
272     @Override
273     protected void nextIncomingFrame(Frame frame)
274     {
275         if (frame.isFin() && !incomingContextTakeover)
276         {
277             LOG.debug("Incoming Context Reset");
278             decompressor.reset();
279         }
280 
281         super.nextIncomingFrame(frame);
282     }
283 
284     @Override
285     protected void nextOutgoingFrame(Frame frame, WriteCallback callback)
286     {
287         if (frame.isFin() && !outgoingContextTakeover)
288         {
289             LOG.debug("Outgoing Context Reset");
290             compressor.reset();
291         }
292 
293         super.nextOutgoingFrame(frame,callback);
294     }
295 
296     @Override
297     public void setConfig(final ExtensionConfig config)
298     {
299         configRequested = new ExtensionConfig(config);
300         configNegotiated = new ExtensionConfig(config.getName());
301 
302         boolean nowrap = true;
303         compressor = new Deflater(Deflater.BEST_COMPRESSION,nowrap);
304         compressor.setStrategy(Deflater.DEFAULT_STRATEGY);
305 
306         decompressor = new Inflater(nowrap);
307 
308         for (String key : config.getParameterKeys())
309         {
310             key = key.trim();
311             switch (key)
312             {
313                 case "client_max_window_bits": // fallthru
314                 case "server_max_window_bits":
315                     // Not supported by Jetty
316                     // Don't negotiate these parameters
317                     break;
318                 case "client_no_context_takeover":
319                     configNegotiated.setParameter("client_no_context_takeover");
320                     switch (getPolicy().getBehavior())
321                     {
322                         case CLIENT:
323                             incomingContextTakeover = false;
324                             break;
325                         case SERVER:
326                             outgoingContextTakeover = false;
327                             break;
328                     }
329                     break;
330                 case "server_no_context_takeover":
331                     configNegotiated.setParameter("server_no_context_takeover");
332                     switch (getPolicy().getBehavior())
333                     {
334                         case CLIENT:
335                             outgoingContextTakeover = false;
336                             break;
337                         case SERVER:
338                             incomingContextTakeover = false;
339                             break;
340                     }
341                     break;
342             }
343         }
344 
345         super.setConfig(configNegotiated);
346     }
347 
348     @Override
349     public String toString()
350     {
351         StringBuilder str = new StringBuilder();
352         str.append(this.getClass().getSimpleName());
353         str.append("[requested=").append(configRequested.getParameterizedName());
354         str.append(",negotiated=").append(configNegotiated.getParameterizedName());
355         str.append(']');
356         return str.toString();
357     }
358 }