001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase.io.compress;
019
020import static org.junit.jupiter.api.Assertions.assertEquals;
021import static org.junit.jupiter.api.Assertions.assertFalse;
022import static org.junit.jupiter.api.Assertions.assertTrue;
023import static org.junit.jupiter.api.Assertions.fail;
024import static org.junit.jupiter.api.Assumptions.assumeTrue;
025
026import java.io.ByteArrayOutputStream;
027import java.io.IOException;
028import java.io.UncheckedIOException;
029import java.nio.ByteBuffer;
030import java.util.Arrays;
031import java.util.zip.GZIPOutputStream;
032import org.apache.hadoop.conf.Configuration;
033import org.apache.hadoop.hbase.nio.ByteBuff;
034import org.apache.hadoop.hbase.nio.MultiByteBuff;
035import org.apache.hadoop.hbase.nio.SingleByteBuff;
036import org.apache.hadoop.hbase.testclassification.SmallTests;
037import org.apache.hadoop.hbase.util.Bytes;
038import org.apache.hadoop.util.NativeCodeLoader;
039import org.junit.jupiter.api.Tag;
040import org.junit.jupiter.api.Test;
041
042@Tag(SmallTests.TAG)
043public class TestGzipByteBuffDecompressor {
044
045  // A single gzip member, reused as decompressor input across the tests.
046  private static final byte[] COMPRESSED_PAYLOAD = gzip("HBase is fun to use and very fast");
047
048  /**
049   * GzipByteBuffDecompressor is backed by Hadoop's native zlib binding, so actually decompressing
050   * anything requires that native library to be loaded on this JVM.
051   */
052  private static void assumeNativeZlibLoaded() {
053    assumeTrue(NativeCodeLoader.isNativeCodeLoaded(),
054      "Hadoop's native code is not loaded on this JVM, skipping");
055  }
056
057  @Test
058  public void itReportsCorrectCapabilitiesWithoutNativeZlib() {
059    ByteBuff emptySingleDirectBuff = new SingleByteBuff(ByteBuffer.allocateDirect(0));
060    ByteBuff emptySingleHeapBuff = new SingleByteBuff(ByteBuffer.allocate(0));
061    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(false)) {
062      assertFalse(decompressor.canDecompress(emptySingleDirectBuff, emptySingleDirectBuff),
063        "Without native zlib, direct-to-direct decompression is not available");
064      assertFalse(decompressor.canDecompress(emptySingleHeapBuff, emptySingleHeapBuff),
065        "Heap decompression is not supported; only direct-to-direct");
066    }
067  }
068
069  @Test
070  public void itReportsCorrectCapabilitiesWithNativeZlib() {
071    assumeNativeZlibLoaded();
072    ByteBuff emptySingleHeapBuff = new SingleByteBuff(ByteBuffer.allocate(0));
073    ByteBuff emptyMultiHeapBuff = new MultiByteBuff(ByteBuffer.allocate(0), ByteBuffer.allocate(0));
074    ByteBuff emptySingleDirectBuff = new SingleByteBuff(ByteBuffer.allocateDirect(0));
075    ByteBuff emptyMultiDirectBuff =
076      new MultiByteBuff(ByteBuffer.allocateDirect(0), ByteBuffer.allocateDirect(0));
077
078    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
079      assertTrue(decompressor.canDecompress(emptySingleDirectBuff, emptySingleDirectBuff));
080      // Only direct-to-direct is supported; heap and mixed buffers return false.
081      assertFalse(decompressor.canDecompress(emptySingleHeapBuff, emptySingleHeapBuff));
082      assertFalse(decompressor.canDecompress(emptySingleHeapBuff, emptySingleDirectBuff));
083      assertFalse(decompressor.canDecompress(emptySingleDirectBuff, emptySingleHeapBuff));
084      assertFalse(decompressor.canDecompress(emptyMultiHeapBuff, emptyMultiHeapBuff));
085      assertFalse(decompressor.canDecompress(emptyMultiDirectBuff, emptyMultiDirectBuff));
086      assertFalse(decompressor.canDecompress(emptySingleDirectBuff, emptyMultiDirectBuff));
087    }
088  }
089
090  private static ByteBuff directBuffWith(byte[] data) {
091    ByteBuffer buffer = ByteBuffer.allocateDirect(data.length);
092    buffer.put(data);
093    buffer.rewind();
094    return new SingleByteBuff(buffer);
095  }
096
097  private static ByteBuff heapBuffWith(byte[] data) {
098    ByteBuffer buffer = ByteBuffer.allocate(data.length);
099    buffer.put(data);
100    buffer.rewind();
101    return new SingleByteBuff(buffer);
102  }
103
104  private static byte[] gzip(String text) {
105    ByteArrayOutputStream compressed = new ByteArrayOutputStream();
106    try (GZIPOutputStream out = new GZIPOutputStream(compressed)) {
107      out.write(Bytes.toBytes(text));
108    } catch (IOException e) {
109      throw new UncheckedIOException(e);
110    }
111    return compressed.toByteArray();
112  }
113
114  private static byte[] concat(byte[] first, byte[] second) {
115    byte[] combined = new byte[first.length + second.length];
116    System.arraycopy(first, 0, combined, 0, first.length);
117    System.arraycopy(second, 0, combined, first.length, second.length);
118    return combined;
119  }
120
121  @Test
122  public void itDecompressesDirectToDirectSuccessfully() throws IOException {
123    assumeNativeZlibLoaded();
124    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
125      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
126      ByteBuff input = directBuffWith(COMPRESSED_PAYLOAD);
127      int decompressedSize = decompressor.decompress(output, input, COMPRESSED_PAYLOAD.length);
128      assertEquals("HBase is fun to use and very fast",
129        Bytes.toString(output.toBytes(0, decompressedSize)));
130    }
131  }
132
133  @Test
134  public void itDecompressDirectFailsOnTooShortInput() throws IOException {
135    assumeNativeZlibLoaded();
136    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
137      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
138      ByteBuff input = new SingleByteBuff(ByteBuffer.allocateDirect(10));
139      decompressor.decompress(output, input, 10);
140      fail("Expected an IOException because the input is too short to be a gzip member");
141    } catch (IOException e) {
142      assertTrue(e.getMessage().contains("too short to be a gzip member"));
143    }
144  }
145
146  @Test
147  public void itDecompressDirectFailsOnBadMagicBytes() throws IOException {
148    assumeNativeZlibLoaded();
149    byte[] corrupted = Arrays.copyOf(COMPRESSED_PAYLOAD, COMPRESSED_PAYLOAD.length);
150    corrupted[0] ^= (byte) 0xff;
151    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
152      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
153      ByteBuff input = directBuffWith(corrupted);
154      decompressor.decompress(output, input, corrupted.length);
155      fail("Expected an IOException because the magic bytes are wrong");
156    } catch (IOException e) {
157      assertTrue(e.getMessage().contains("Invalid gzip stream"));
158    }
159  }
160
161  @Test
162  public void itDecompressDirectFailsWhenOutputBufferTooSmall() throws IOException {
163    assumeNativeZlibLoaded();
164    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
165      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(10));
166      ByteBuff input = directBuffWith(COMPRESSED_PAYLOAD);
167      decompressor.decompress(output, input, COMPRESSED_PAYLOAD.length);
168      fail("Expected an IOException because the output buffer is too small");
169    } catch (IOException e) {
170      assertTrue(e.getMessage().contains("Output buffer is too small"));
171    }
172  }
173
174  @Test
175  public void itDecompressDirectFailsOnCorruptedCrc32() throws IOException {
176    assumeNativeZlibLoaded();
177    byte[] corrupted = Arrays.copyOf(COMPRESSED_PAYLOAD, COMPRESSED_PAYLOAD.length);
178    // First 4 bytes of the 8-byte trailer are the CRC32, leave ISIZE (the last 4 bytes) alone.
179    corrupted[corrupted.length - 8] ^= (byte) 0xff;
180    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
181      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
182      ByteBuff input = directBuffWith(corrupted);
183      decompressor.decompress(output, input, corrupted.length);
184      fail("Expected an IOException because the trailer's CRC32 no longer matches");
185    } catch (IOException e) {
186      assertTrue(e.getMessage().contains("Invalid gzip stream"));
187    }
188  }
189
190  @Test
191  public void itDecompressDirectFailsOnCorruptedIsize() throws IOException {
192    assumeNativeZlibLoaded();
193    byte[] corrupted = Arrays.copyOf(COMPRESSED_PAYLOAD, COMPRESSED_PAYLOAD.length);
194    // Last 4 bytes of the 8-byte trailer are the ISIZE.
195    corrupted[corrupted.length - 4] ^= (byte) 0xff;
196    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
197      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
198      ByteBuff input = directBuffWith(corrupted);
199      decompressor.decompress(output, input, corrupted.length);
200      fail("Expected an IOException because the trailer's ISIZE no longer matches");
201    } catch (IOException e) {
202      assertTrue(e.getMessage().contains("Invalid gzip stream"));
203    }
204  }
205
206  @Test
207  public void itDecompressesDirectSuccessfullyOnRepeatedCalls() throws IOException {
208    assumeNativeZlibLoaded();
209    // Mirrors how CodecPool actually uses these: one instance is reused across many blocks, so the
210    // native decompressor must produce a correct result on every call, not just the first.
211    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
212      for (int i = 0; i < 3; i++) {
213        ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
214        ByteBuff input = directBuffWith(COMPRESSED_PAYLOAD);
215        int decompressedSize = decompressor.decompress(output, input, COMPRESSED_PAYLOAD.length);
216        assertEquals("HBase is fun to use and very fast",
217          Bytes.toString(output.toBytes(0, decompressedSize)));
218      }
219    }
220  }
221
222  @Test
223  public void itDecompressDirectIsStillUsableAfterAPreviousCallThrows() throws IOException {
224    assumeNativeZlibLoaded();
225    byte[] corrupted = Arrays.copyOf(COMPRESSED_PAYLOAD, COMPRESSED_PAYLOAD.length);
226    // First 4 bytes of the 8-byte trailer are the CRC32.
227    corrupted[corrupted.length - 8] ^= (byte) 0xff;
228    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
229      ByteBuff badOutput = new SingleByteBuff(ByteBuffer.allocateDirect(64));
230      ByteBuff badInput = directBuffWith(corrupted);
231      try {
232        decompressor.decompress(badOutput, badInput, corrupted.length);
233        fail("Expected an IOException because the trailer's CRC32 no longer matches");
234      } catch (IOException e) {
235        assertTrue(e.getMessage().contains("Invalid gzip stream"));
236      }
237
238      // A prior failure must not leave the shared native decompressor state corrupted for the
239      // next, valid call.
240      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
241      ByteBuff input = directBuffWith(COMPRESSED_PAYLOAD);
242      int decompressedSize = decompressor.decompress(output, input, COMPRESSED_PAYLOAD.length);
243      assertEquals("HBase is fun to use and very fast",
244        Bytes.toString(output.toBytes(0, decompressedSize)));
245    }
246  }
247
248  @Test
249  public void itDecompressesDirectToDirectWithNonZeroBufferPosition() throws IOException {
250    assumeNativeZlibLoaded();
251    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
252      ByteBuffer rawOutput = ByteBuffer.allocateDirect(128);
253      rawOutput.position(32);
254
255      ByteBuffer rawInput = ByteBuffer.allocateDirect(16 + COMPRESSED_PAYLOAD.length);
256      for (int i = 0; i < 16; i++) {
257        rawInput.put((byte) 0);
258      }
259      rawInput.put(COMPRESSED_PAYLOAD);
260      rawInput.position(16);
261
262      ByteBuff output = new SingleByteBuff(rawOutput);
263      ByteBuff input = new SingleByteBuff(rawInput);
264      int decompressedSize = decompressor.decompress(output, input, COMPRESSED_PAYLOAD.length);
265
266      byte[] result = new byte[decompressedSize];
267      rawOutput.position(32);
268      rawOutput.get(result);
269      assertEquals("HBase is fun to use and very fast", Bytes.toString(result));
270    }
271  }
272
273  @Test
274  public void itDecompressDirectFailsOnTruncatedGzipStream() throws IOException {
275    assumeNativeZlibLoaded();
276    byte[] truncated = Arrays.copyOf(COMPRESSED_PAYLOAD, COMPRESSED_PAYLOAD.length - 4);
277    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
278      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
279      ByteBuff input = directBuffWith(truncated);
280      decompressor.decompress(output, input, truncated.length);
281      fail("Expected an IOException because the gzip stream is truncated");
282    } catch (IOException e) {
283      // Expected: the decompressor must not report finished() on an incomplete stream
284    }
285  }
286
287  @Test
288  public void itDecompressesOnlyTheDelimitedMemberFromAMultiMemberPayload() throws IOException {
289    assumeNativeZlibLoaded();
290    // Two distinct members concatenated: we must decode only the one delimited by inputLen.
291    byte[] firstMember = gzip("first member");
292    byte[] secondMember = gzip("second member");
293    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
294      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
295      ByteBuff input = directBuffWith(concat(firstMember, secondMember));
296      int decompressedSize = decompressor.decompress(output, input, firstMember.length);
297      assertEquals("first member", Bytes.toString(output.toBytes(0, decompressedSize)));
298    }
299  }
300
301  /**
302   * This is the exact gate {@code HFileBlockDefaultDecodingContext#canDecompressViaByteBuff} relies
303   * on to decide between ByteBuff decompression and the stream path, driven end-to-end from the
304   * {@code GzipHFileDecompressionContext#ALLOW_BYTE_BUFF_DECOMPRESSION_KEY} config flag.
305   */
306  @Test
307  public void itReinitControlsByteBuffDecompressionViaConfigFlag() {
308    assumeNativeZlibLoaded();
309    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
310      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
311      ByteBuff input = directBuffWith(COMPRESSED_PAYLOAD);
312
313      Configuration conf = new Configuration(false);
314      conf.setBoolean(GzipHFileDecompressionContext.ALLOW_BYTE_BUFF_DECOMPRESSION_KEY, false);
315      decompressor.reinit(GzipHFileDecompressionContext.fromConfiguration(conf));
316      assertFalse(decompressor.canDecompress(output, input),
317        "Block reader must fall back to stream decompression when the config flag "
318          + "disables ByteBuff decompression");
319
320      conf.setBoolean(GzipHFileDecompressionContext.ALLOW_BYTE_BUFF_DECOMPRESSION_KEY, true);
321      decompressor.reinit(GzipHFileDecompressionContext.fromConfiguration(conf));
322      assertTrue(decompressor.canDecompress(output, input),
323        "Block reader must use ByteBuff decompression when the config flag is enabled");
324
325      // The default, with no config value set, must also allow ByteBuff decompression.
326      decompressor
327        .reinit(GzipHFileDecompressionContext.fromConfiguration(new Configuration(false)));
328      assertTrue(decompressor.canDecompress(output, input));
329    }
330  }
331
332  @Test
333  public void itReinitWithNullContextIsNoOp() {
334    assumeNativeZlibLoaded();
335    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(true)) {
336      ByteBuff output = new SingleByteBuff(ByteBuffer.allocateDirect(64));
337      ByteBuff input = directBuffWith(COMPRESSED_PAYLOAD);
338
339      Configuration conf = new Configuration(false);
340      conf.setBoolean(GzipHFileDecompressionContext.ALLOW_BYTE_BUFF_DECOMPRESSION_KEY, false);
341      decompressor.reinit(GzipHFileDecompressionContext.fromConfiguration(conf));
342      assertFalse(decompressor.canDecompress(output, input));
343
344      decompressor.reinit(null);
345      assertFalse(decompressor.canDecompress(output, input),
346        "reinit(null) must not reset allowByteBuffDecompression back to the default");
347    }
348  }
349
350  @Test
351  public void itReinitFailsOnWrongContextType() {
352    Compression.HFileDecompressionContext wrongContext =
353      new Compression.HFileDecompressionContext() {
354        @Override
355        public void close() {
356        }
357
358        @Override
359        public long heapSize() {
360          return 0;
361        }
362      };
363    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(false)) {
364      decompressor.reinit(wrongContext);
365      fail("Expected an IllegalArgumentException because the context was not a "
366        + "GzipHFileDecompressionContext");
367    } catch (IllegalArgumentException e) {
368      assertTrue(e.getMessage().contains("GzipHFileDecompressionContext"));
369    }
370  }
371
372  @Test
373  public void itDecompressThrowsWhenPassedAMultiByteBuff() throws IOException {
374    try (GzipByteBuffDecompressor decompressor = new GzipByteBuffDecompressor(false)) {
375      ByteBuff multiOutput = new MultiByteBuff(ByteBuffer.allocate(64), ByteBuffer.allocate(64));
376      ByteBuff input = heapBuffWith(COMPRESSED_PAYLOAD);
377      decompressor.decompress(multiOutput, input, COMPRESSED_PAYLOAD.length);
378      fail("Expected an IllegalStateException when output is a MultiByteBuff");
379    } catch (IllegalStateException e) {
380      assertTrue(e.getMessage().contains("not a SingleByteBuff"));
381    }
382  }
383
384}