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 io.netty.buffer.ByteBuf;
19  import io.netty.buffer.ByteBufAllocator;
20  import io.netty.util.internal.UnstableApi;
21  
22  import java.util.zip.Adler32;
23  import java.util.zip.Checksum;
24  
25  import static io.netty.handler.codec.compression.FastLz.BLOCK_TYPE_COMPRESSED;
26  import static io.netty.handler.codec.compression.FastLz.BLOCK_WITH_CHECKSUM;
27  import static io.netty.handler.codec.compression.FastLz.MAGIC_NUMBER;
28  import static io.netty.handler.codec.compression.FastLz.decompress;
29  
30  /**
31   * Uncompresses a {@link ByteBuf} encoded by {@link FastLzFrameEncoder} using the FastLZ algorithm.
32   *
33   * See <a href="https://github.com/netty/netty/issues/2750">FastLZ format</a>.
34   * <p>
35   * This API is still in progress as part of the decompression migration tracked by
36   * <a href="https://github.com/netty/netty/issues/16743">#16743</a>.
37   */
38  @UnstableApi
39  public final class FastLzFrameDecompressor extends InputBufferingDecompressor {
40      /**
41       * Current state of decompression.
42       */
43      private enum State {
44          INIT_BLOCK,
45          INIT_BLOCK_PARAMS,
46          DECOMPRESS_DATA,
47          COMPLETE
48      }
49  
50      private State currentState = State.INIT_BLOCK;
51  
52      /**
53       * Underlying checksum calculator in use.
54       */
55      private final ByteBufChecksum checksum;
56  
57      /**
58       * Length of the current received chunk of data.
59       */
60      private int chunkLength;
61  
62      /**
63       * Original length of the current received chunk of data.
64       * It is equal to {@link #chunkLength} for uncompressed chunks.
65       */
66      private int originalLength;
67  
68      /**
69       * Indicates whether this chunk is compressed.
70       */
71      private boolean isCompressed;
72  
73      /**
74       * Indicates whether this chunk has a checksum.
75       */
76      private boolean hasChecksum;
77  
78      /**
79       * Checksum value of the current received chunk of data when present.
80       */
81      private int currentChecksum;
82  
83      FastLzFrameDecompressor(Builder builder, ByteBufAllocator allocator) {
84          super(allocator);
85          this.checksum = builder.checksum == null ? null : ByteBufChecksum.wrapChecksum(builder.checksum);
86      }
87  
88      @Override
89      void processInput(ByteBuf buf) throws DecompressionException {
90          switch (currentState) {
91              case INIT_BLOCK:
92                  if (buf.readableBytes() < 4) {
93                      break;
94                  }
95  
96                  final int magic = buf.readUnsignedMedium();
97                  if (magic != MAGIC_NUMBER) {
98                      throw new DecompressionException("unexpected block identifier");
99                  }
100 
101                 final byte options = buf.readByte();
102                 isCompressed = (options & 0x01) == BLOCK_TYPE_COMPRESSED;
103                 hasChecksum = (options & 0x10) == BLOCK_WITH_CHECKSUM;
104 
105                 currentState = State.INIT_BLOCK_PARAMS;
106                 // fall through
107             case INIT_BLOCK_PARAMS:
108                 if (buf.readableBytes() < 2 + (isCompressed ? 2 : 0) + (hasChecksum ? 4 : 0)) {
109                     break;
110                 }
111                 currentChecksum = hasChecksum ? buf.readInt() : 0;
112                 chunkLength = buf.readUnsignedShort();
113                 originalLength = isCompressed ? buf.readUnsignedShort() : chunkLength;
114 
115                 currentState = State.DECOMPRESS_DATA;
116                 // fall through
117             case DECOMPRESS_DATA:
118                 break;
119             default:
120                 throw new IllegalStateException();
121         }
122     }
123 
124     @Override
125     public Status status() throws DecompressionException {
126         switch (currentState) {
127             case INIT_BLOCK:
128             case INIT_BLOCK_PARAMS:
129                 return Status.NEED_INPUT;
130             case DECOMPRESS_DATA:
131                 if (available() < chunkLength) {
132                     return Status.NEED_INPUT;
133                 } else {
134                     return Status.NEED_OUTPUT;
135                 }
136             case COMPLETE:
137                 return Status.COMPLETE;
138             default:
139                 throw new AssertionError("Unknown state: " + currentState);
140         }
141     }
142 
143     @Override
144     public void endOfInput() throws DecompressionException {
145         if (currentState != State.INIT_BLOCK) {
146             throw new DecompressionException("Unexpected end of input");
147         }
148         currentState = State.COMPLETE;
149     }
150 
151     @Override
152     ByteBuf processOutput(ByteBuf in) throws DecompressionException {
153         final int chunkLength = this.chunkLength;
154         if (in.readableBytes() < chunkLength) {
155             throw new IllegalStateException("Not in state NEED_OUTPUT");
156         }
157 
158         final int idx = in.readerIndex();
159         final int originalLength = this.originalLength;
160 
161         ByteBuf output = null;
162 
163         try {
164             if (isCompressed) {
165                 output = allocator.buffer(originalLength);
166                 int outputOffset = output.writerIndex();
167                 final int decompressedBytes = decompress(in, idx, chunkLength,
168                         output, outputOffset, originalLength);
169                 if (originalLength != decompressedBytes) {
170                     throw new DecompressionException(String.format(
171                             "stream corrupted: originalLength(%d) and actual length(%d) mismatch",
172                             originalLength, decompressedBytes));
173                 }
174                 output.writerIndex(output.writerIndex() + decompressedBytes);
175             } else {
176                 output = in.retainedSlice(idx, chunkLength);
177             }
178 
179             final ByteBufChecksum checksum = this.checksum;
180             if (hasChecksum && checksum != null) {
181                 checksum.reset();
182                 checksum.update(output, output.readerIndex(), output.readableBytes());
183                 final int checksumResult = (int) checksum.getValue();
184                 if (checksumResult != currentChecksum) {
185                     throw new DecompressionException(String.format(
186                             "stream corrupted: mismatching checksum: %d (expected: %d)",
187                             checksumResult, currentChecksum));
188                 }
189             }
190 
191             in.skipBytes(chunkLength);
192 
193             currentState = State.INIT_BLOCK;
194             ByteBuf b = output;
195             output = null;
196             return b;
197         } finally {
198             if (output != null) {
199                 output.release();
200             }
201         }
202     }
203 
204     @UnstableApi
205     public static Builder builder() {
206         return new Builder();
207     }
208 
209     @UnstableApi
210     public static final class Builder extends AbstractDecompressorBuilder {
211         private Checksum checksum;
212 
213         Builder() {
214         }
215 
216         /**
217          * A checksum to use to validate each block. Defaults to no checksum validation.
218          *
219          * @param checksum The checksum to use for validation
220          * @return This builder
221          */
222         @UnstableApi
223         public Builder checksum(Checksum checksum) {
224             this.checksum = checksum;
225             return this;
226         }
227 
228         /**
229          * Enable validation using the default checksum, Adler32.
230          *
231          * @return This builder
232          */
233         @UnstableApi
234         public Builder defaultChecksum() {
235             return checksum(new Adler32());
236         }
237 
238         @Override
239         @UnstableApi
240         public Decompressor build(ByteBufAllocator allocator) throws DecompressionException {
241             return new DefensiveDecompressor(new FastLzFrameDecompressor(this, allocator));
242         }
243     }
244 }