View Javadoc
1   /*
2    * Copyright 2012 The Netty Project
3    *
4    * The Netty Project licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  package io.netty.handler.codec.http.multipart;
17  
18  import io.netty.buffer.ByteBuf;
19  import io.netty.buffer.Unpooled;
20  import io.netty.handler.codec.http.HttpConstants;
21  import io.netty.handler.codec.http.HttpContent;
22  import io.netty.handler.codec.http.HttpRequest;
23  import io.netty.handler.codec.http.LastHttpContent;
24  import io.netty.handler.codec.http.QueryStringDecoder;
25  import io.netty.handler.codec.http.HttpHeaderValues;
26  import io.netty.handler.codec.http.HttpHeaderNames;
27  import io.netty.handler.codec.http.multipart.HttpPostBodyUtil.SeekAheadOptimize;
28  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.EndOfDataDecoderException;
29  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.ErrorDataDecoderException;
30  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.MultiPartStatus;
31  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.NotEnoughDataDecoderException;
32  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.TooManyFormFieldsException;
33  import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.TooLongFormFieldException;
34  import io.netty.util.ByteProcessor;
35  import io.netty.util.internal.PlatformDependent;
36  import io.netty.util.internal.StringUtil;
37  
38  import java.io.IOException;
39  import java.nio.charset.Charset;
40  import java.util.ArrayList;
41  import java.util.List;
42  import java.util.Map;
43  import java.util.TreeMap;
44  
45  import static io.netty.util.internal.ObjectUtil.*;
46  
47  /**
48   * This decoder will decode Body and can handle POST BODY.
49   *
50   * You <strong>MUST</strong> call {@link #destroy()} after completion to release all resources.
51   *
52   */
53  public class HttpPostStandardRequestDecoder implements InterfaceHttpPostRequestDecoder {
54  
55      /**
56       * Factory used to create InterfaceHttpData
57       */
58      private final HttpDataFactory factory;
59  
60      /**
61       * Request to decode
62       */
63      private final HttpRequest request;
64  
65      /**
66       * Default charset to use
67       */
68      private final Charset charset;
69  
70      /**
71       * The maximum number of fields allows by the form
72       */
73      private final int maxFields;
74  
75      /**
76       * The maximum number of accumulated bytes when decoding a field
77       */
78      private final int maxBufferedBytes;
79  
80      /**
81       * Does the last chunk already received
82       */
83      private boolean isLastChunk;
84  
85      /**
86       * HttpDatas from Body
87       */
88      private final List<InterfaceHttpData> bodyListHttpData = new ArrayList<InterfaceHttpData>();
89  
90      /**
91       * HttpDatas as Map from Body
92       */
93      private final Map<String, List<InterfaceHttpData>> bodyMapHttpData = new TreeMap<String, List<InterfaceHttpData>>(
94              CaseIgnoringComparator.INSTANCE);
95  
96      /**
97       * The current channelBuffer
98       */
99      private ByteBuf undecodedChunk;
100 
101     /**
102      * Body HttpDatas current position
103      */
104     private int bodyListHttpDataRank;
105 
106     /**
107      * Current getStatus
108      */
109     private MultiPartStatus currentStatus = MultiPartStatus.NOTSTARTED;
110 
111     /**
112      * The current Attribute that is currently in decode process
113      */
114     private Attribute currentAttribute;
115 
116     private boolean destroyed;
117 
118     private int discardThreshold = HttpPostRequestDecoder.DEFAULT_DISCARD_THRESHOLD;
119 
120     /**
121      *
122      * @param request
123      *            the request to decode
124      * @throws NullPointerException
125      *             for request
126      * @throws ErrorDataDecoderException
127      *             if the default charset was wrong when decoding or other
128      *             errors
129      */
130     public HttpPostStandardRequestDecoder(HttpRequest request) {
131         this(new DefaultHttpDataFactory(DefaultHttpDataFactory.MINSIZE), request, HttpConstants.DEFAULT_CHARSET);
132     }
133 
134     /**
135      *
136      * @param factory
137      *            the factory used to create InterfaceHttpData
138      * @param request
139      *            the request to decode
140      * @throws NullPointerException
141      *             for request or factory
142      * @throws ErrorDataDecoderException
143      *             if the default charset was wrong when decoding or other
144      *             errors
145      */
146     public HttpPostStandardRequestDecoder(HttpDataFactory factory, HttpRequest request) {
147         this(factory, request, HttpConstants.DEFAULT_CHARSET);
148     }
149 
150     /**
151      *
152      * @param factory
153      *            the factory used to create InterfaceHttpData
154      * @param request
155      *            the request to decode
156      * @param charset
157      *            the charset to use as default
158      * @throws NullPointerException
159      *             for request or charset or factory
160      * @throws ErrorDataDecoderException
161      *             if the default charset was wrong when decoding or other
162      *             errors
163      */
164     public HttpPostStandardRequestDecoder(HttpDataFactory factory, HttpRequest request, Charset charset) {
165         this(factory, request, charset, HttpPostRequestDecoder.DEFAULT_MAX_FIELDS,
166                 HttpPostRequestDecoder.DEFAULT_MAX_BUFFERED_BYTES);
167     }
168 
169     /**
170      *
171      * @param factory
172      *            the factory used to create InterfaceHttpData
173      * @param request
174      *            the request to decode
175      * @param charset
176      *            the charset to use as default
177      * @param maxFields
178      *            the maximum number of fields the form can have, {@code -1} to disable
179      * @param maxBufferedBytes
180      *            the maximum number of bytes the decoder can buffer when decoding a field, {@code -1} to disable
181      * @throws NullPointerException
182      *             for request or charset or factory
183      * @throws ErrorDataDecoderException
184      *             if the default charset was wrong when decoding or other
185      *             errors
186      */
187     public HttpPostStandardRequestDecoder(HttpDataFactory factory, HttpRequest request, Charset charset,
188                                           int maxFields, int maxBufferedBytes) {
189         this.request = checkNotNull(request, "request");
190         this.charset = checkNotNull(charset, "charset");
191         this.factory = checkNotNull(factory, "factory");
192         this.maxFields = maxFields;
193         this.maxBufferedBytes = maxBufferedBytes;
194         try {
195             if (request instanceof HttpContent) {
196                 // Offer automatically if the given request is as type of HttpContent
197                 // See #1089
198                 offer((HttpContent) request);
199             } else {
200                 parseBody();
201             }
202         } catch (Throwable e) {
203             destroy();
204             PlatformDependent.throwException(e);
205         }
206     }
207 
208     private void checkDestroyed() {
209         if (destroyed) {
210             throw new IllegalStateException(HttpPostStandardRequestDecoder.class.getSimpleName()
211                     + " was destroyed already");
212         }
213     }
214 
215     /**
216      * True if this request is a Multipart request
217      *
218      * @return True if this request is a Multipart request
219      */
220     @Override
221     public boolean isMultipart() {
222         checkDestroyed();
223         return false;
224     }
225 
226     /**
227      * Set the amount of bytes after which read bytes in the buffer should be discarded.
228      * Setting this lower gives lower memory usage but with the overhead of more memory copies.
229      * Use {@code 0} to disable it.
230      */
231     @Override
232     public void setDiscardThreshold(int discardThreshold) {
233         this.discardThreshold = checkPositiveOrZero(discardThreshold, "discardThreshold");
234     }
235 
236     /**
237      * Return the threshold in bytes after which read data in the buffer should be discarded.
238      */
239     @Override
240     public int getDiscardThreshold() {
241         return discardThreshold;
242     }
243 
244     /**
245      * This getMethod returns a List of all HttpDatas from body.<br>
246      *
247      * If chunked, all chunks must have been offered using offer() getMethod. If
248      * not, NotEnoughDataDecoderException will be raised.
249      *
250      * @return the list of HttpDatas from Body part for POST getMethod
251      * @throws NotEnoughDataDecoderException
252      *             Need more chunks
253      */
254     @Override
255     public List<InterfaceHttpData> getBodyHttpDatas() {
256         checkDestroyed();
257 
258         if (!isLastChunk) {
259             throw new NotEnoughDataDecoderException();
260         }
261         return bodyListHttpData;
262     }
263 
264     /**
265      * This getMethod returns a List of all HttpDatas with the given name from
266      * body.<br>
267      *
268      * If chunked, all chunks must have been offered using offer() getMethod. If
269      * not, NotEnoughDataDecoderException will be raised.
270      *
271      * @return All Body HttpDatas with the given name (ignore case)
272      * @throws NotEnoughDataDecoderException
273      *             need more chunks
274      */
275     @Override
276     public List<InterfaceHttpData> getBodyHttpDatas(String name) {
277         checkDestroyed();
278 
279         if (!isLastChunk) {
280             throw new NotEnoughDataDecoderException();
281         }
282         return bodyMapHttpData.get(name);
283     }
284 
285     /**
286      * This getMethod returns the first InterfaceHttpData with the given name from
287      * body.<br>
288      *
289      * If chunked, all chunks must have been offered using offer() getMethod. If
290      * not, NotEnoughDataDecoderException will be raised.
291      *
292      * @return The first Body InterfaceHttpData with the given name (ignore
293      *         case)
294      * @throws NotEnoughDataDecoderException
295      *             need more chunks
296      */
297     @Override
298     public InterfaceHttpData getBodyHttpData(String name) {
299         checkDestroyed();
300 
301         if (!isLastChunk) {
302             throw new NotEnoughDataDecoderException();
303         }
304         List<InterfaceHttpData> list = bodyMapHttpData.get(name);
305         if (list != null) {
306             return list.get(0);
307         }
308         return null;
309     }
310 
311     /**
312      * Initialized the internals from a new chunk
313      *
314      * @param content
315      *            the new received chunk
316      * @throws ErrorDataDecoderException
317      *             if there is a problem with the charset decoding or other
318      *             errors
319      */
320     @Override
321     public HttpPostStandardRequestDecoder offer(HttpContent content) {
322         checkDestroyed();
323 
324         if (content instanceof LastHttpContent) {
325             isLastChunk = true;
326         }
327 
328         ByteBuf buf = content.content();
329         if (undecodedChunk == null) {
330             undecodedChunk =
331                     // Since the Handler will release the incoming later on, we need to copy it
332                     //
333                     // We are explicit allocate a buffer and NOT calling copy() as otherwise it may set a maxCapacity
334                     // which is not really usable for us as we may exceed it once we add more bytes.
335                     buf.alloc().buffer(buf.readableBytes()).writeBytes(buf);
336         } else {
337             undecodedChunk.writeBytes(buf);
338         }
339         parseBody();
340         if (maxBufferedBytes > 0 && undecodedChunk != null && undecodedChunk.readableBytes() > maxBufferedBytes) {
341             throw new TooLongFormFieldException();
342         }
343         if (undecodedChunk != null && undecodedChunk.writerIndex() > discardThreshold) {
344             if (undecodedChunk.refCnt() == 1) {
345                 // It's safe to call discardBytes() as we are the only owner of the buffer.
346                 undecodedChunk.discardReadBytes();
347             } else {
348                 // There seems to be multiple references of the buffer. Let's copy the data and release the buffer to
349                 // ensure we can give back memory to the system.
350                 ByteBuf buffer = undecodedChunk.alloc().buffer(undecodedChunk.readableBytes());
351                 buffer.writeBytes(undecodedChunk);
352                 undecodedChunk.release();
353                 undecodedChunk = buffer;
354             }
355         }
356         return this;
357     }
358 
359     /**
360      * True if at current getStatus, there is an available decoded
361      * InterfaceHttpData from the Body.
362      *
363      * This getMethod works for chunked and not chunked request.
364      *
365      * @return True if at current getStatus, there is a decoded InterfaceHttpData
366      * @throws EndOfDataDecoderException
367      *             No more data will be available
368      */
369     @Override
370     public boolean hasNext() {
371         checkDestroyed();
372 
373         if (currentStatus == MultiPartStatus.EPILOGUE) {
374             // OK except if end of list
375             if (bodyListHttpDataRank >= bodyListHttpData.size()) {
376                 throw new EndOfDataDecoderException();
377             }
378         }
379         return !bodyListHttpData.isEmpty() && bodyListHttpDataRank < bodyListHttpData.size();
380     }
381 
382     /**
383      * Returns the next available InterfaceHttpData or null if, at the time it
384      * is called, there is no more available InterfaceHttpData. A subsequent
385      * call to offer(httpChunk) could enable more data.
386      *
387      * Be sure to call {@link InterfaceHttpData#release()} after you are done
388      * with processing to make sure to not leak any resources
389      *
390      * @return the next available InterfaceHttpData or null if none
391      * @throws EndOfDataDecoderException
392      *             No more data will be available
393      */
394     @Override
395     public InterfaceHttpData next() {
396         checkDestroyed();
397 
398         if (hasNext()) {
399             return bodyListHttpData.get(bodyListHttpDataRank++);
400         }
401         return null;
402     }
403 
404     @Override
405     public InterfaceHttpData currentPartialHttpData() {
406         return currentAttribute;
407     }
408 
409     /**
410      * This getMethod will parse as much as possible data and fill the list and map
411      *
412      * @throws ErrorDataDecoderException
413      *             if there is a problem with the charset decoding or other
414      *             errors
415      */
416     private void parseBody() {
417         if (currentStatus == MultiPartStatus.PREEPILOGUE || currentStatus == MultiPartStatus.EPILOGUE) {
418             if (isLastChunk) {
419                 currentStatus = MultiPartStatus.EPILOGUE;
420             }
421             return;
422         }
423         parseBodyAttributes();
424     }
425 
426     /**
427      * Utility function to add a new decoded data
428      */
429     protected void addHttpData(InterfaceHttpData data) {
430         if (data == null) {
431             return;
432         }
433         if (maxFields > 0 && bodyListHttpData.size() >= maxFields) {
434             throw new TooManyFormFieldsException();
435         }
436         List<InterfaceHttpData> datas = bodyMapHttpData.get(data.getName());
437         if (datas == null) {
438             datas = new ArrayList<InterfaceHttpData>(1);
439             bodyMapHttpData.put(data.getName(), datas);
440         }
441         datas.add(data);
442         bodyListHttpData.add(data);
443     }
444 
445     /**
446      * This getMethod fill the map and list with as much Attribute as possible from
447      * Body in not Multipart mode.
448      *
449      * @throws ErrorDataDecoderException
450      *             if there is a problem with the charset decoding or other
451      *             errors
452      */
453     private void parseBodyAttributesStandard() {
454         int firstpos = undecodedChunk.readerIndex();
455         int currentpos = firstpos;
456         int equalpos;
457         int ampersandpos;
458         if (currentStatus == MultiPartStatus.NOTSTARTED) {
459             currentStatus = MultiPartStatus.DISPOSITION;
460         }
461         boolean contRead = true;
462         try {
463             while (undecodedChunk.isReadable() && contRead) {
464                 char read = (char) undecodedChunk.readUnsignedByte();
465                 currentpos++;
466                 switch (currentStatus) {
467                 case DISPOSITION:// search '='
468                     if (read == '=') {
469                         currentStatus = MultiPartStatus.FIELD;
470                         equalpos = currentpos - 1;
471                         String key = decodeAttribute(undecodedChunk.toString(firstpos, equalpos - firstpos, charset),
472                                 charset);
473                         currentAttribute = factory.createAttribute(request, key);
474                         firstpos = currentpos;
475                     } else if (read == '&' ||
476                             (isLastChunk && !undecodedChunk.isReadable() && hasFormBody())) { // special empty FIELD
477                         currentStatus = MultiPartStatus.DISPOSITION;
478                         ampersandpos = read == '&' ? currentpos - 1 : currentpos;
479                         String key = decodeAttribute(
480                                 undecodedChunk.toString(firstpos, ampersandpos - firstpos, charset), charset);
481                         // Some weird request bodies start with an '&' character, eg: &name=J&age=17.
482                         // In that case, key would be "", will get exception:
483                         // java.lang.IllegalArgumentException: Param 'name' must not be empty;
484                         // Just check and skip empty key.
485                         if (!key.isEmpty()) {
486                             currentAttribute = factory.createAttribute(request, key);
487                             currentAttribute.setValue(""); // empty
488                             addHttpData(currentAttribute);
489                         }
490                         currentAttribute = null;
491                         firstpos = currentpos;
492                         contRead = true;
493                     }
494                     break;
495                 case FIELD:// search '&' or end of line
496                     if (read == '&') {
497                         currentStatus = MultiPartStatus.DISPOSITION;
498                         ampersandpos = currentpos - 1;
499                         setFinalBuffer(undecodedChunk.retainedSlice(firstpos, ampersandpos - firstpos));
500                         firstpos = currentpos;
501                         contRead = true;
502                     } else if (read == HttpConstants.CR) {
503                         if (undecodedChunk.isReadable()) {
504                             read = (char) undecodedChunk.readUnsignedByte();
505                             currentpos++;
506                             if (read == HttpConstants.LF) {
507                                 currentStatus = MultiPartStatus.PREEPILOGUE;
508                                 ampersandpos = currentpos - 2;
509                                 setFinalBuffer(undecodedChunk.retainedSlice(firstpos, ampersandpos - firstpos));
510                                 firstpos = currentpos;
511                                 contRead = false;
512                             } else {
513                                 // Error
514                                 throw new ErrorDataDecoderException("Bad end of line");
515                             }
516                         } else {
517                             currentpos--;
518                         }
519                     } else if (read == HttpConstants.LF) {
520                         currentStatus = MultiPartStatus.PREEPILOGUE;
521                         ampersandpos = currentpos - 1;
522                         setFinalBuffer(undecodedChunk.retainedSlice(firstpos, ampersandpos - firstpos));
523                         firstpos = currentpos;
524                         contRead = false;
525                     }
526                     break;
527                 default:
528                     // just stop
529                     contRead = false;
530                 }
531             }
532             if (isLastChunk && currentAttribute != null) {
533                 // special case
534                 ampersandpos = currentpos;
535                 if (ampersandpos > firstpos) {
536                     setFinalBuffer(undecodedChunk.retainedSlice(firstpos, ampersandpos - firstpos));
537                 } else if (!currentAttribute.isCompleted()) {
538                     setFinalBuffer(Unpooled.EMPTY_BUFFER);
539                 }
540                 firstpos = currentpos;
541                 currentStatus = MultiPartStatus.EPILOGUE;
542             } else if (contRead && currentAttribute != null && currentStatus == MultiPartStatus.FIELD) {
543                 // reset index except if to continue in case of FIELD getStatus
544                 currentAttribute.addContent(undecodedChunk.retainedSlice(firstpos, currentpos - firstpos),
545                                             false);
546                 firstpos = currentpos;
547             }
548             undecodedChunk.readerIndex(firstpos);
549         } catch (ErrorDataDecoderException e) {
550             // error while decoding
551             undecodedChunk.readerIndex(firstpos);
552             throw e;
553         } catch (IOException | IllegalArgumentException e) {
554             // error while decoding
555             undecodedChunk.readerIndex(firstpos);
556             throw new ErrorDataDecoderException(e);
557         }
558     }
559 
560     /**
561      * This getMethod fill the map and list with as much Attribute as possible from
562      * Body in not Multipart mode.
563      *
564      * @throws ErrorDataDecoderException
565      *             if there is a problem with the charset decoding or other
566      *             errors
567      */
568     private void parseBodyAttributes() {
569         if (undecodedChunk == null) {
570             return;
571         }
572         parseBodyAttributesStandard();
573     }
574 
575     private void setFinalBuffer(ByteBuf buffer) throws IOException {
576         currentAttribute.addContent(buffer, true);
577         ByteBuf decodedBuf = decodeAttribute(currentAttribute.getByteBuf(), charset);
578         if (decodedBuf != null) { // override content only when ByteBuf needed decoding
579             currentAttribute.setContent(decodedBuf);
580         }
581         addHttpData(currentAttribute);
582         currentAttribute = null;
583     }
584 
585     /**
586      * Decode component
587      *
588      * @return the decoded component
589      */
590     private static String decodeAttribute(String s, Charset charset) {
591         try {
592             return QueryStringDecoder.decodeComponent(s, charset);
593         } catch (IllegalArgumentException e) {
594             throw new ErrorDataDecoderException("Bad string: '" + s + '\'', e);
595         }
596     }
597 
598     private static ByteBuf decodeAttribute(ByteBuf b, Charset charset) {
599         int firstEscaped = b.forEachByte(new UrlEncodedDetector());
600         if (firstEscaped == -1) {
601             return null; // nothing to decode
602         }
603 
604         ByteBuf buf = b.alloc().buffer(b.readableBytes());
605         UrlDecoder urlDecode = new UrlDecoder(buf);
606         int idx = b.forEachByte(urlDecode);
607         if (urlDecode.nextEscapedIdx != 0) { // incomplete hex byte
608             if (idx == -1) {
609                 idx = b.readableBytes() - 1;
610             }
611             idx -= urlDecode.nextEscapedIdx - 1;
612             buf.release();
613             throw new ErrorDataDecoderException(
614                 String.format("Invalid hex byte at index '%d' in string: '%s'", idx, b.toString(charset)));
615         }
616 
617         return buf;
618     }
619 
620     /**
621      * Destroy the {@link HttpPostStandardRequestDecoder} and release all it resources. After this method
622      * was called it is not possible to operate on it anymore.
623      */
624     @Override
625     public void destroy() {
626         // Release all data items, including those not yet pulled, only file based items
627         cleanFiles();
628         // Clean Memory based data
629         for (InterfaceHttpData httpData : bodyListHttpData) {
630             // Might have been already released by the user
631             if (httpData.refCnt() > 0) {
632                 httpData.release();
633             }
634         }
635 
636         destroyed = true;
637 
638         if (undecodedChunk != null && undecodedChunk.refCnt() > 0) {
639             undecodedChunk.release();
640             undecodedChunk = null;
641         }
642     }
643 
644     /**
645      * Clean all {@link HttpData}s for the current request.
646      */
647     @Override
648     public void cleanFiles() {
649         checkDestroyed();
650 
651         factory.cleanRequestHttpData(request);
652     }
653 
654     /**
655      * Remove the given FileUpload from the list of FileUploads to clean
656      */
657     @Override
658     public void removeHttpDataFromClean(InterfaceHttpData data) {
659         checkDestroyed();
660 
661         factory.removeHttpDataFromClean(request, data);
662     }
663 
664     /**
665      * Check if request has headers indicating that it contains form body
666      */
667     private boolean hasFormBody() {
668         String contentHeaderValue = request.headers().get(HttpHeaderNames.CONTENT_TYPE);
669         if (contentHeaderValue == null) {
670             return false;
671         }
672         return HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.contentEquals(contentHeaderValue)
673                 || HttpHeaderValues.MULTIPART_FORM_DATA.contentEquals(contentHeaderValue);
674     }
675 
676     private static final class UrlEncodedDetector implements ByteProcessor {
677         @Override
678         public boolean process(byte value) throws Exception {
679             return value != '%' && value != '+';
680         }
681     }
682 
683     private static final class UrlDecoder implements ByteProcessor {
684 
685         private final ByteBuf output;
686         private int nextEscapedIdx;
687         private byte hiByte;
688 
689         UrlDecoder(ByteBuf output) {
690             this.output = output;
691         }
692 
693         @Override
694         public boolean process(byte value) {
695             if (nextEscapedIdx != 0) {
696                 if (nextEscapedIdx == 1) {
697                     hiByte = value;
698                     ++nextEscapedIdx;
699                 } else {
700                     int hi = StringUtil.decodeHexNibble((char) hiByte);
701                     int lo = StringUtil.decodeHexNibble((char) value);
702                     if (hi == -1 || lo == -1) {
703                         ++nextEscapedIdx;
704                         return false;
705                     }
706                     output.writeByte((hi << 4) + lo);
707                     nextEscapedIdx = 0;
708                 }
709             } else if (value == '%') {
710                 nextEscapedIdx = 1;
711             } else if (value == '+') {
712                 output.writeByte(' ');
713             } else {
714                 output.writeByte(value);
715             }
716             return true;
717         }
718     }
719 }