View Javadoc
1   /*
2    * Copyright 2025 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.compression;
17  
18  import com.aayushatharva.brotli4j.decoder.DecoderJNI;
19  import io.netty.buffer.ByteBuf;
20  import io.netty.buffer.ByteBufAllocator;
21  import io.netty.util.internal.ObjectUtil;
22  import io.netty.util.internal.UnstableApi;
23  
24  import java.io.IOException;
25  import java.nio.ByteBuffer;
26  
27  /**
28   * Decompresses a {@link ByteBuf} encoded with the brotli format.
29   *
30   * See <a href="https://github.com/google/brotli">brotli</a>.
31   */
32  @UnstableApi
33  public final class BrotliDecompressor implements Decompressor {
34      private final ByteBufAllocator allocator;
35      private DecoderJNI.Wrapper decoder;
36      private ByteBuf unusedInput;
37      private boolean pulledSinceLastPush;
38  
39      static {
40          try {
41              Brotli.ensureAvailability();
42          } catch (Throwable throwable) {
43              throw new ExceptionInInitializerError(throwable);
44          }
45      }
46  
47      BrotliDecompressor(Builder builder, ByteBufAllocator allocator) throws DecompressionException {
48          this.allocator = allocator;
49          try {
50              this.decoder = new DecoderJNI.Wrapper(builder.inputBufferSize, builder.maxOutputChunkSize);
51          } catch (IOException ioe) {
52              throw new DecompressionException(ioe);
53          }
54      }
55  
56      @Override
57      public Status status() throws DecompressionException {
58          while (true) {
59              switch (decoder.getStatus()) {
60                  case ERROR:
61                      throw new DecompressionException("Brotli error status");
62                  case DONE:
63                      return Status.COMPLETE;
64                  case NEEDS_MORE_INPUT:
65                      if (decoder.hasOutput()) {
66                          return Status.NEED_OUTPUT;
67                      }
68                      if (unusedInput == null) {
69                          if (pulledSinceLastPush) {
70                              // After a pull, the decoder reports NEEDS_MORE_INPUT as soon as all pushed input is
71                              // consumed, even if it could still produce more output from its internal state
72                              // (e.g. a long back-reference). Push without new input to find out.
73                              push(0);
74                              break;
75                          }
76                          return Status.NEED_INPUT;
77                      }
78                      addSomeInput(unusedInput);
79                      if (!unusedInput.isReadable()) {
80                          unusedInput.release();
81                          unusedInput = null;
82                      }
83                      break;
84                  case OK:
85                      push(0);
86                      break;
87                  case NEEDS_MORE_OUTPUT:
88                      return Status.NEED_OUTPUT;
89                  default:
90                      throw new AssertionError("Unknown status: " + decoder.getStatus());
91              }
92          }
93      }
94  
95      @Override
96      public void addInput(ByteBuf buf) throws DecompressionException {
97          try {
98              if (unusedInput != null) {
99                  throw new IllegalStateException("Not in state NEED_INPUT");
100             }
101             addSomeInput(buf);
102         } catch (Throwable t) {
103             buf.release();
104             throw t;
105         }
106         if (buf.isReadable()) {
107             this.unusedInput = buf;
108         } else {
109             buf.release();
110         }
111     }
112 
113     private void addSomeInput(ByteBuf buf) {
114         ByteBuffer decoderInputBuffer = decoder.getInputBuffer();
115         decoderInputBuffer.clear();
116         int readBytes = readBytes(buf, decoderInputBuffer);
117         push(readBytes);
118     }
119 
120     private void push(int length) {
121         pulledSinceLastPush = false;
122         decoder.push(length);
123     }
124 
125     @Override
126     public void endOfInput() throws DecompressionException {
127         if (decoder.getStatus() == DecoderJNI.Status.NEEDS_MORE_INPUT) {
128             assert unusedInput == null : "Expected to be in NEED_INPUT state";
129             push(0);
130             if (decoder.getStatus() == DecoderJNI.Status.NEEDS_MORE_INPUT) {
131                 throw new DecompressionException("Truncated brotli stream");
132             }
133         }
134     }
135 
136     @Override
137     public ByteBuf takeOutput() throws DecompressionException {
138         ByteBuffer nativeBuffer = decoder.pull();
139         pulledSinceLastPush = true;
140         // nativeBuffer actually wraps brotli's internal buffer so we need to copy its content
141         // size limited by maxOutputChunkSize
142         ByteBuf copy = allocator.buffer(nativeBuffer.remaining());
143         copy.writeBytes(nativeBuffer);
144         return copy;
145     }
146 
147     @Override
148     public void close() {
149         if (decoder != null) {
150             decoder.destroy();
151             decoder = null;
152         }
153         if (unusedInput != null) {
154             unusedInput.release();
155             unusedInput = null;
156         }
157     }
158 
159     private static int readBytes(ByteBuf in, ByteBuffer dest) {
160         int limit = Math.min(in.readableBytes(), dest.remaining());
161         ByteBuffer slice = dest.slice();
162         slice.limit(limit);
163         in.readBytes(slice);
164         dest.position(dest.position() + limit);
165         return limit;
166     }
167 
168     @UnstableApi
169     public static Builder builder() {
170         return new Builder();
171     }
172 
173     @UnstableApi
174     public static final class Builder extends AbstractDecompressorBuilder {
175         private int inputBufferSize = 8 * 1024;
176         private int maxOutputChunkSize = 64 * 1024;
177 
178         Builder() {
179         }
180 
181         /**
182          * Desired size of the input buffer in bytes. Default 8K.
183          *
184          * @param inputBufferSize desired size of the input buffer in bytes
185          * @return This builder
186          */
187         public Builder inputBufferSize(int inputBufferSize) {
188             this.inputBufferSize = ObjectUtil.checkPositive(inputBufferSize, "inputBufferSize");
189             return this;
190         }
191 
192         /**
193          * Number of bytes of output to consume at a time. Default 64K.
194          *
195          * @param maxOutputChunkSize Maximum output chunk size
196          * @return This builder
197          */
198         public Builder maxOutputChunkSize(int maxOutputChunkSize) {
199             this.maxOutputChunkSize = ObjectUtil.checkPositive(maxOutputChunkSize, "maxOutputChunkSize");
200             return this;
201         }
202 
203         @Override
204         public Decompressor build(ByteBufAllocator allocator) throws DecompressionException {
205             return new DefensiveDecompressor(new BrotliDecompressor(this, allocator));
206         }
207     }
208 }