diff --git a/src/main/java/com/amazon/ion/BufferConfiguration.java b/src/main/java/com/amazon/ion/BufferConfiguration.java index 53bad7097..c67b623ee 100644 --- a/src/main/java/com/amazon/ion/BufferConfiguration.java +++ b/src/main/java/com/amazon/ion/BufferConfiguration.java @@ -3,8 +3,6 @@ package com.amazon.ion; -import com.amazon.ion.impl._Private_IonConstants; - /** * Provides logic common to all BufferConfiguration implementations. * @param the type of the concrete subclass of this BufferConfiguration. @@ -47,6 +45,12 @@ public static abstract class Builder< Configuration extends BufferConfiguration, BuilderType extends BufferConfiguration.Builder > { + /** + * The default maximum buffer size: 100 MB. This provides protection against memory exhaustion + * from malicious inputs that declare excessively large lengths, while remaining large enough + * for any realistic well-formed Ion stream. + */ + public static final int DEFAULT_MAXIMUM_BUFFER_SIZE = 100 * 1024 * 1024; /** * Large enough that most streams will never need to grow the buffer. NOTE: this only needs to be large @@ -62,7 +66,13 @@ public static abstract class Builder< /** * The maximum number of bytes that will be buffered. */ - private int maximumBufferSize = _Private_IonConstants.ARRAY_MAXIMUM_SIZE; + private int maximumBufferSize = DEFAULT_MAXIMUM_BUFFER_SIZE; + + /** + * Tracks whether the user has explicitly set the maximum buffer size via + * {@link #withMaximumBufferSize(int)}. + */ + private boolean maximumBufferSizeExplicitlySet = false; /** * The handler that will be notified when oversized values are encountered. @@ -135,13 +145,14 @@ public final DataHandler getDataHandler() { * Set the maximum size of the buffer. For binary Ion, the minimum value is 5 because all valid binary Ion data * begins with a 4-byte Ion version marker and the smallest value is 1 byte. For text Ion, the minimum value is * 2 because the smallest text Ion value is 1 byte and the smallest delimiter is 1 byte. - * Default: Near to the maximum size of an array. + * Default: 100 MB ({@link #DEFAULT_MAXIMUM_BUFFER_SIZE}). * * @param maximumBufferSizeInBytes the value. * @return this builder. */ public final BuilderType withMaximumBufferSize(final int maximumBufferSizeInBytes) { maximumBufferSize = maximumBufferSizeInBytes; + maximumBufferSizeExplicitlySet = true; return (BuilderType) this; } @@ -152,6 +163,14 @@ public int getMaximumBufferSize() { return maximumBufferSize; } + /** + * @return true if the maximum buffer size was explicitly set by the user via + * {@link #withMaximumBufferSize(int)}, false if using the default. + */ + public boolean isMaximumBufferSizeExplicitlySet() { + return maximumBufferSizeExplicitlySet; + } + /** * Gets the minimum allowed maximum buffer size. * @return the value. @@ -216,7 +235,7 @@ protected BufferConfiguration(Builder builder) { )); } if (builder.getOversizedValueHandler() == null) { - requireMaximumBufferSize(); + requireMaximumBufferSize(builder); oversizedValueHandler = builder.getThrowingOversizedValueHandler(); } else { oversizedValueHandler = builder.getOversizedValueHandler(); @@ -229,10 +248,11 @@ protected BufferConfiguration(Builder builder) { } /** - * Requires that the maximum buffer size not be limited. + * Requires an OversizedValueHandler when the user explicitly sets a custom maximum buffer size. + * When using the default, a throwing handler is applied automatically. */ - private void requireMaximumBufferSize() { - if (maximumBufferSize < _Private_IonConstants.ARRAY_MAXIMUM_SIZE) { + private void requireMaximumBufferSize(Builder builder) { + if (builder.isMaximumBufferSizeExplicitlySet()) { throw new IllegalArgumentException( "Must specify an OversizedValueHandler when a custom maximum buffer size is specified." ); diff --git a/src/main/java/com/amazon/ion/IonBufferConfiguration.java b/src/main/java/com/amazon/ion/IonBufferConfiguration.java index efcfa49f6..d473a12b7 100644 --- a/src/main/java/com/amazon/ion/IonBufferConfiguration.java +++ b/src/main/java/com/amazon/ion/IonBufferConfiguration.java @@ -3,8 +3,6 @@ package com.amazon.ion; -import com.amazon.ion.impl._Private_IonConstants; - /** * Configures buffers that hold Ion data. */ @@ -180,7 +178,7 @@ public IonBufferConfiguration build() { private IonBufferConfiguration(Builder builder) { super(builder); if (builder.getOversizedSymbolTableHandler() == null) { - requireMaximumBufferSize(); + requireMaximumBufferSize(builder); oversizedSymbolTableHandler = builder.getThrowingOversizedSymbolTableHandler(); } else { oversizedSymbolTableHandler = builder.getOversizedSymbolTableHandler(); @@ -188,10 +186,12 @@ private IonBufferConfiguration(Builder builder) { } /** - * Requires that the maximum buffer size not be limited. + * Requires that the maximum buffer size not be limited unless the user explicitly configured it. + * When the user explicitly sets a custom maximum buffer size, an OversizedSymbolTableHandler is required. + * When using the default maximum buffer size, the throwing handler is used automatically. */ - private void requireMaximumBufferSize() { - if (getMaximumBufferSize() < _Private_IonConstants.ARRAY_MAXIMUM_SIZE) { + private void requireMaximumBufferSize(Builder builder) { + if (builder.isMaximumBufferSizeExplicitlySet()) { throw new IllegalArgumentException( "Must specify both an OversizedValueHandler and OversizedSymbolTableHandler when a custom maximum buffer size is specified." ); diff --git a/src/main/java/com/amazon/ion/impl/UnifiedInputStreamX.java b/src/main/java/com/amazon/ion/impl/UnifiedInputStreamX.java index 2858ab352..5ad882f30 100644 --- a/src/main/java/com/amazon/ion/impl/UnifiedInputStreamX.java +++ b/src/main/java/com/amazon/ion/impl/UnifiedInputStreamX.java @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.amazon.ion.impl; +import com.amazon.ion.IonException; import com.amazon.ion.impl.UnifiedSavePointManagerX.SavePoint; import java.io.Closeable; import java.io.IOException; @@ -77,6 +78,16 @@ abstract class UnifiedInputStreamX UnifiedSavePointManagerX _save_points; + /** + * The maximum total bytes that may be loaded across all pages, or -1 for no limit. + */ + long _maximumTotalBytes = -1; + + /** + * Tracks the total bytes loaded across all pages. + */ + long _totalBytesLoaded = 0; + // factories to construct an appropriate input stream // based on the input source public static UnifiedInputStreamX makeStream(CharSequence chars) { @@ -94,6 +105,9 @@ public static UnifiedInputStreamX makeStream(char[] chars, int offset, int lengt public static UnifiedInputStreamX makeStream(Reader reader) throws IOException { return new FromCharStream(reader); } + public static UnifiedInputStreamX makeStream(Reader reader, long maximumTotalBytes) throws IOException { + return new FromCharStream(reader, maximumTotalBytes); + } public static UnifiedInputStreamX makeStream(byte[] buffer) { return new FromByteArray(buffer, 0, buffer.length); } @@ -103,6 +117,9 @@ public static UnifiedInputStreamX makeStream(byte[] buffer, int offset, int leng public static UnifiedInputStreamX makeStream(InputStream stream) throws IOException { return new FromByteStream(stream); } + public static UnifiedInputStreamX makeStream(InputStream stream, long maximumTotalBytes) throws IOException { + return new FromByteStream(stream, maximumTotalBytes); + } public final InputStream getInputStream() { return _stream; } public final Reader getReader() { return _reader; } public final byte[] getByteArray() { return _bytes; } @@ -498,6 +515,14 @@ protected final int load(UnifiedDataPageX curr, int start_pos, long file_positio else { read = curr.load(_reader, start_pos, file_position); } + if (read > 0) { + _totalBytesLoaded += read; + if (_maximumTotalBytes > 0 && _totalBytesLoaded > _maximumTotalBytes) { + throw new IonException( + "Text stream exceeded maximum buffer size of " + _maximumTotalBytes + " bytes" + ); + } + } } return read; } @@ -531,10 +556,16 @@ private static class FromCharArray extends UnifiedInputStreamX private static class FromCharStream extends UnifiedInputStreamX { FromCharStream(Reader reader) throws IOException + { + this(reader, -1); + } + + FromCharStream(Reader reader, long maximumTotalBytes) throws IOException { _is_byte_data = false; _is_stream = true; _reader = reader; + _maximumTotalBytes = maximumTotalBytes; // If this page size ever becomes configurable watch out for _Private_IonConstants.ARRAY_MAXIMUM_SIZE _buffer = UnifiedInputBufferX.makePageBuffer(UnifiedInputBufferX.BufferType.CHARS, DEFAULT_PAGE_SIZE); super.init(); @@ -569,10 +600,16 @@ static class FromByteArray extends UnifiedInputStreamX private static class FromByteStream extends UnifiedInputStreamX { FromByteStream(InputStream stream) throws IOException + { + this(stream, -1); + } + + FromByteStream(InputStream stream, long maximumTotalBytes) throws IOException { _is_byte_data = true; _is_stream = true; _stream = stream; + _maximumTotalBytes = maximumTotalBytes; // If this page size ever becomes configurable watch out for _Private_IonConstants.ARRAY_MAXIMUM_SIZE _buffer = UnifiedInputBufferX.makePageBuffer(UnifiedInputBufferX.BufferType.BYTES, DEFAULT_PAGE_SIZE); super.init(); diff --git a/src/main/java/com/amazon/ion/impl/_Private_IonReaderBuilder.java b/src/main/java/com/amazon/ion/impl/_Private_IonReaderBuilder.java index a7c50db90..5e2cc8795 100644 --- a/src/main/java/com/amazon/ion/impl/_Private_IonReaderBuilder.java +++ b/src/main/java/com/amazon/ion/impl/_Private_IonReaderBuilder.java @@ -2,12 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 package com.amazon.ion.impl; +import com.amazon.ion.IonBufferConfiguration; import com.amazon.ion.IonCatalog; import com.amazon.ion.IonException; import com.amazon.ion.IonReader; import com.amazon.ion.IonTextReader; import com.amazon.ion.IonValue; import com.amazon.ion.util.InputStreamInterceptor; +import com.amazon.ion.util.LimitedInflaterInputStream; import com.amazon.ion.system.IonReaderBuilder; import com.amazon.ion.util.IonStreamUtils; @@ -205,6 +207,24 @@ private static void validateHeaderLength(int maxHeaderLength) { } } + /** + * Wraps the given InputStream with a {@link LimitedInflaterInputStream} if the interceptor is for GZIP + * and a finite maximum buffer size is configured. + */ + private static InputStream wrapWithInflationLimitIfNeeded( + _Private_IonReaderBuilder builder, + InputStreamInterceptor streamInterceptor, + InputStream interceptedStream + ) { + if ("gzip".equals(streamInterceptor.formatName())) { + long maxDecompressedBytes = builder.getBufferConfiguration().getMaximumBufferSize(); + if (maxDecompressedBytes > 0) { + return new LimitedInflaterInputStream(interceptedStream, maxDecompressedBytes); + } + } + return interceptedStream; + } + static IonReader buildReader( _Private_IonReaderBuilder builder, byte[] ionData, @@ -222,9 +242,11 @@ static IonReader buildReader( } if (streamInterceptor.isMatch(ionData, offset, length)) { try { + InputStream decompressedStream = streamInterceptor.newInputStream(new ByteArrayInputStream(ionData, offset, length)); + decompressedStream = wrapWithInflationLimitIfNeeded(builder, streamInterceptor, decompressedStream); return buildReader( builder, - streamInterceptor.newInputStream(new ByteArrayInputStream(ionData, offset, length)), + decompressedStream, _Private_IonReaderFactory::makeReaderBinary, _Private_IonReaderFactory::makeReaderText, // The builder provides only one level of detection, e.g. GZIP-compressed binary Ion *or* @@ -275,7 +297,7 @@ private static boolean startsWithIvm(byte[] buffer, int length) { @FunctionalInterface interface IonReaderFromInputStreamFactoryText { - IonReader makeReader(IonCatalog catalog, InputStream source, _Private_LocalSymbolTableFactory lstFactory); + IonReader makeReader(IonCatalog catalog, InputStream source, _Private_LocalSymbolTableFactory lstFactory, long maximumTotalBytes); } @FunctionalInterface @@ -357,6 +379,7 @@ static IonReader buildReader( ionData = streamInterceptor.newInputStream( new TwoElementInputStream(new ByteArrayInputStream(possibleIVM, 0, bytesRead), ionData) ); + ionData = wrapWithInflationLimitIfNeeded(builder, streamInterceptor, ionData); } catch (IOException e) { throw new IonException(e); } @@ -376,7 +399,7 @@ static IonReader buildReader( } else { wrapper = ionData; } - return text.makeReader(builder.validateCatalog(), wrapper, builder.lstFactory); + return text.makeReader(builder.validateCatalog(), wrapper, builder.lstFactory, builder.getBufferConfiguration().getMaximumBufferSize()); } @Override @@ -393,7 +416,7 @@ public IonReader build(InputStream source) @Override public IonReader build(Reader ionText) { - return makeReaderText(validateCatalog(), ionText, lstFactory); + return makeReaderText(validateCatalog(), ionText, lstFactory, getBufferConfiguration().getMaximumBufferSize()); } @Override diff --git a/src/main/java/com/amazon/ion/impl/_Private_IonReaderFactory.java b/src/main/java/com/amazon/ion/impl/_Private_IonReaderFactory.java index 735ac4a40..6717ebfd8 100644 --- a/src/main/java/com/amazon/ion/impl/_Private_IonReaderFactory.java +++ b/src/main/java/com/amazon/ion/impl/_Private_IonReaderFactory.java @@ -80,10 +80,18 @@ public static final IonReader makeSystemReaderText(CharSequence chars) public static final IonReader makeReaderText(IonCatalog catalog, InputStream is, _Private_LocalSymbolTableFactory lstFactory) + { + return makeReaderText(catalog, is, lstFactory, -1); + } + + public static final IonReader makeReaderText(IonCatalog catalog, + InputStream is, + _Private_LocalSymbolTableFactory lstFactory, + long maximumTotalBytes) { UnifiedInputStreamX uis; try { - uis = makeUnifiedStream(is); + uis = makeUnifiedStream(is, maximumTotalBytes); } catch (IOException e) { throw new IonException(e); } @@ -104,12 +112,13 @@ public static IonReader makeSystemReaderText(InputStream is) private static IonReader makeSystemReaderText(IonCatalog catalog, InputStream is, - _Private_LocalSymbolTableFactory lstFactory) + _Private_LocalSymbolTableFactory lstFactory, + long maximumTotalBytes) { UnifiedInputStreamX uis; try { - uis = makeUnifiedStream(is); + uis = makeUnifiedStream(is, maximumTotalBytes); } catch (IOException e) { @@ -138,9 +147,22 @@ private static IonReader makeSystemReaderText(IonCatalog catalog, public static final IonTextReader makeReaderText(IonCatalog catalog, Reader chars, _Private_LocalSymbolTableFactory lstFactory) + { + return makeReaderText(catalog, chars, lstFactory, -1); + } + + public static final IonTextReader makeReaderText(IonCatalog catalog, + Reader chars, + _Private_LocalSymbolTableFactory lstFactory, + long maximumTotalBytes) { try { - UnifiedInputStreamX in = makeStream(chars); + UnifiedInputStreamX in; + if (maximumTotalBytes > 0) { + in = makeStream(chars, maximumTotalBytes); + } else { + in = makeStream(chars); + } return new IonReaderTextUserX(catalog, lstFactory, in); } catch (IOException e) { @@ -225,14 +247,19 @@ private static UnifiedInputStreamX makeUnifiedStream(byte[] bytes, return uis; } - private static UnifiedInputStreamX makeUnifiedStream(InputStream in) + private static UnifiedInputStreamX makeUnifiedStream(InputStream in, long maximumTotalBytes) throws IOException { in.getClass(); // Force NPE // TODO avoid multiple wrapping streams, use the UIS for the pushback in = IonStreamUtils.unGzip(in); - UnifiedInputStreamX uis = UnifiedInputStreamX.makeStream(in); + UnifiedInputStreamX uis; + if (maximumTotalBytes > 0) { + uis = UnifiedInputStreamX.makeStream(in, maximumTotalBytes); + } else { + uis = UnifiedInputStreamX.makeStream(in); + } return uis; } } diff --git a/src/main/java/com/amazon/ion/system/IonReaderBuilder.java b/src/main/java/com/amazon/ion/system/IonReaderBuilder.java index a3927705c..7ade6e615 100644 --- a/src/main/java/com/amazon/ion/system/IonReaderBuilder.java +++ b/src/main/java/com/amazon/ion/system/IonReaderBuilder.java @@ -101,6 +101,7 @@ public abstract class IonReaderBuilder private boolean isIncrementalReadingEnabled = false; private IonBufferConfiguration bufferConfiguration = IonBufferConfiguration.DEFAULT; private List streamInterceptors = null; + private boolean gzipDecompressionEnabled = true; protected IonReaderBuilder() { @@ -112,6 +113,7 @@ protected IonReaderBuilder(IonReaderBuilder that) this.isIncrementalReadingEnabled = that.isIncrementalReadingEnabled; this.bufferConfiguration = that.bufferConfiguration; this.streamInterceptors = that.streamInterceptors == null ? null : new ArrayList<>(that.streamInterceptors); + this.gzipDecompressionEnabled = that.gzipDecompressionEnabled; } /** @@ -385,12 +387,69 @@ private static List detectStreamInterceptorsOnClasspath( * @return an unmodifiable view of the stream interceptors currently configured. */ public List getInputStreamInterceptors() { + if (!gzipDecompressionEnabled) { + if (streamInterceptors == null) { + // Filter out GzipStreamInterceptor from the detected list + List filtered = new ArrayList<>(); + for (InputStreamInterceptor interceptor : DETECTED_STREAM_INTERCEPTORS) { + if (!(interceptor instanceof GzipStreamInterceptor)) { + filtered.add(interceptor); + } + } + return Collections.unmodifiableList(filtered); + } + // Filter out GzipStreamInterceptor from the custom list + List filtered = new ArrayList<>(); + for (InputStreamInterceptor interceptor : streamInterceptors) { + if (!(interceptor instanceof GzipStreamInterceptor)) { + filtered.add(interceptor); + } + } + return Collections.unmodifiableList(filtered); + } if (streamInterceptors == null) { return DETECTED_STREAM_INTERCEPTORS; } return Collections.unmodifiableList(streamInterceptors); } + /** + * Declares whether GZIP auto-decompression is enabled when building an {@link IonReader}, + * returning a new mutable builder if the current one is immutable. + * + * When enabled (the default), GZIP-compressed Ion data is automatically detected and decompressed. + * When disabled, GZIP-compressed data is not auto-decompressed. + * + * @param enabled true to enable GZIP auto-decompression (default), false to disable. + * @return this builder instance, if mutable; otherwise a mutable copy of this builder. + */ + public IonReaderBuilder withGzipDecompressionEnabled(boolean enabled) { + IonReaderBuilder b = mutable(); + b.gzipDecompressionEnabled = enabled; + return b; + } + + /** + * Sets whether GZIP auto-decompression is enabled. + * + * @param enabled true to enable GZIP auto-decompression (default), false to disable. + * + * @see #withGzipDecompressionEnabled(boolean) + * + * @throws UnsupportedOperationException if this builder is immutable. + */ + public void setGzipDecompressionEnabled(boolean enabled) { + mutationCheck(); + this.gzipDecompressionEnabled = enabled; + } + + /** + * @return true if GZIP auto-decompression is enabled (the default). + */ + public boolean isGzipDecompressionEnabled() { + return gzipDecompressionEnabled; + } + /** * Based on the builder's configuration properties, creates a new IonReader * instance over the given block of Ion data, detecting whether it's text or diff --git a/src/main/java/com/amazon/ion/util/LimitedInflaterInputStream.java b/src/main/java/com/amazon/ion/util/LimitedInflaterInputStream.java new file mode 100644 index 000000000..48635261f --- /dev/null +++ b/src/main/java/com/amazon/ion/util/LimitedInflaterInputStream.java @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package com.amazon.ion.util; + +import com.amazon.ion.IonException; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * An InputStream wrapper that tracks the total number of decompressed bytes read from a GZIP stream + * and throws an {@link IonException} when the configured maximum is exceeded. This provides protection + * against GZIP bomb attacks where a small compressed payload decompresses to an extremely large output. + */ +public final class LimitedInflaterInputStream extends FilterInputStream { + + private final long maxDecompressedBytes; + private long totalBytesRead; + + /** + * Creates a new LimitedInflaterInputStream. + * + * @param in the underlying InputStream (typically a GZIPInputStream) to wrap. + * @param maxDecompressedBytes the maximum number of decompressed bytes allowed to be read + * before throwing an {@link IonException}. + * @throws IllegalArgumentException if maxDecompressedBytes is not positive. + */ + public LimitedInflaterInputStream(InputStream in, long maxDecompressedBytes) { + super(in); + if (maxDecompressedBytes <= 0) { + throw new IllegalArgumentException("maxDecompressedBytes must be positive, got: " + maxDecompressedBytes); + } + this.maxDecompressedBytes = maxDecompressedBytes; + this.totalBytesRead = 0; + } + + @Override + public int read() throws IOException { + int b = in.read(); + if (b >= 0) { + totalBytesRead++; + checkLimit(); + } + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int bytesRead = in.read(b, off, len); + if (bytesRead > 0) { + totalBytesRead += bytesRead; + checkLimit(); + } + return bytesRead; + } + + private void checkLimit() { + if (totalBytesRead > maxDecompressedBytes) { + throw new IonException( + String.format( + "GZIP decompressed size %d exceeds the configured maximum of %d bytes. " + + "This may indicate a GZIP bomb attack. To increase the limit, configure a larger " + + "maximumBufferSize in the IonBufferConfiguration.", + totalBytesRead, + maxDecompressedBytes + ) + ); + } + } +} diff --git a/src/test/java/com/amazon/ion/impl/IonCursorBinaryBufferSizePropagationTest.java b/src/test/java/com/amazon/ion/impl/IonCursorBinaryBufferSizePropagationTest.java new file mode 100644 index 000000000..0ae3f41db --- /dev/null +++ b/src/test/java/com/amazon/ion/impl/IonCursorBinaryBufferSizePropagationTest.java @@ -0,0 +1,428 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package com.amazon.ion.impl; + +import com.amazon.ion.BufferConfiguration; +import com.amazon.ion.IonBufferConfiguration; +import com.amazon.ion.IonException; +import com.amazon.ion.IonReader; +import com.amazon.ion.IonType; +import com.amazon.ion.system.IonReaderBuilder; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.FilterInputStream; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validates that {@code maximumBufferSize} propagation works correctly in {@code IonCursorBinary}. + * + * Verifies that: + * - {@code IonBufferConfiguration.DEFAULT} with the sane default (100 MB) flows through correctly + * - {@code ensureCapacity()} rejects requests exceeding the configured maximum + * - Boundary cases: exactly at limit (succeeds), one byte over limit (triggers rejection) + */ +public class IonCursorBinaryBufferSizePropagationTest { + + /** + * The default maximum buffer size from BufferConfiguration.Builder (100 MB). + */ + private static final int DEFAULT_MAXIMUM_BUFFER_SIZE = BufferConfiguration.Builder.DEFAULT_MAXIMUM_BUFFER_SIZE; + + /** + * Wraps a byte array to force the refillable stream code path (where ensureCapacity() + * checks maximumBufferSize). + */ + private static InputStream asNonByteArrayInputStream(byte[] data) { + return new FilterInputStream(new ByteArrayInputStream(data)) {}; + } + + /** + * Encodes a long value as a VarUInt (variable-length unsigned integer) in Ion binary format. + * Each byte contributes 7 data bits; MSB=1 indicates the final byte (stop bit). + */ + private static byte[] encodeVarUInt(long value) { + if (value < 0) { + throw new IllegalArgumentException("VarUInt value must be non-negative"); + } + if (value == 0) { + return new byte[]{(byte) 0x80}; + } + int numBytes = 0; + long temp = value; + while (temp > 0) { + numBytes++; + temp >>= 7; + } + byte[] result = new byte[numBytes]; + for (int i = numBytes - 1; i >= 0; i--) { + result[i] = (byte) (value & 0x7F); + value >>= 7; + } + result[numBytes - 1] |= (byte) 0x80; + return result; + } + + /** + * Creates a binary Ion payload with a blob declaring the given VarUInt length. + * The actual data after the header is minimal (just a few zeros). + */ + private static byte[] createBinaryIonWithDeclaredLength(long declaredLength) { + byte[] varUInt = encodeVarUInt(declaredLength); + // IVM (4 bytes) + type descriptor (1 byte: 0xAE = blob with VarUInt length) + VarUInt + minimal data + byte[] payload = new byte[4 + 1 + varUInt.length + 5]; + payload[0] = (byte) 0xE0; // IVM + payload[1] = 0x01; + payload[2] = 0x00; + payload[3] = (byte) 0xEA; + payload[4] = (byte) 0xAE; // blob with VarUInt-encoded length + System.arraycopy(varUInt, 0, payload, 5, varUInt.length); + // 5 bytes of actual data (far less than declared) + for (int i = 0; i < 5; i++) { + payload[5 + varUInt.length + i] = 0x00; + } + return payload; + } + + // ---- Propagation Verification ---- + + /** + * Verifies that IonBufferConfiguration.DEFAULT has the expected sane default maximumBufferSize + * of 100 MB, not Integer.MAX_VALUE - 8. + */ + @Test + public void defaultConfigurationHasSaneMaximumBufferSize() { + assertEquals( + DEFAULT_MAXIMUM_BUFFER_SIZE, + IonBufferConfiguration.DEFAULT.getMaximumBufferSize(), + "IonBufferConfiguration.DEFAULT should use the sane default (100 MB)" + ); + assertEquals( + 100 * 1024 * 1024, + DEFAULT_MAXIMUM_BUFFER_SIZE, + "DEFAULT_MAXIMUM_BUFFER_SIZE should be 100 MB" + ); + } + + /** + * Verifies that when using IonBufferConfiguration.DEFAULT (standard configuration), + * the maximumBufferSize flows through to the IonCursorBinary's refillableState. + * This is confirmed by the cursor rejecting requests exceeding the default limit. + */ + @Test + public void defaultMaximumBufferSizePropagates() throws Exception { + long declaredLength = DEFAULT_MAXIMUM_BUFFER_SIZE + 1L; + byte[] payload = createBinaryIonWithDeclaredLength(declaredLength); + + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .build(asNonByteArrayInputStream(payload)); + + try { + reader.next(); + } catch (IonException e) { + // Expected: the throwing OversizedValueHandler fires + } finally { + reader.close(); + } + } + + // ---- ensureCapacity() Rejection Verification ---- + + /** + * Verifies that ensureCapacity() rejects requests exceeding the configured maximumBufferSize. + * When a declared length exceeds the configured maximum, the cursor sets + * isSkippingCurrentValue = true and returns false, triggering the oversized value handler. + */ + @Test + public void ensureCapacityRejectsRequestsExceedingMaximumBufferSize() throws Exception { + final int maxBuffer = 1024; // 1 KB + final AtomicBoolean handlerInvoked = new AtomicBoolean(false); + + IonBufferConfiguration config = IonBufferConfiguration.Builder.standard() + .withInitialBufferSize(32) + .withMaximumBufferSize(maxBuffer) + .onOversizedValue(() -> handlerInvoked.set(true)) + .onOversizedSymbolTable(() -> { throw new IonException("Oversized symbol table"); }) + .build(); + + byte[] payload = createBinaryIonWithDeclaredLength(2048); // exceeds 1 KB limit + + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .withBufferConfiguration(config) + .build(asNonByteArrayInputStream(payload)); + + try { + reader.next(); + assertTrue(handlerInvoked.get(), + "OversizedValueHandler should be invoked when declared length exceeds maximumBufferSize"); + } finally { + try { + reader.close(); + } catch (IonException e) { + // close() may throw when stream is incomplete after skip + } + } + } + + // ---- Boundary Cases ---- + + /** + * Value with declared length exactly at the configured maximum should succeed. + * ensureCapacity() should allocate the buffer normally. + */ + @Test + public void valueExactlyAtMaximumBufferSizeSucceeds() throws Exception { + final int maxBuffer = 512; + final AtomicBoolean handlerInvoked = new AtomicBoolean(false); + + IonBufferConfiguration config = IonBufferConfiguration.Builder.standard() + .withInitialBufferSize(64) + .withMaximumBufferSize(maxBuffer) + .onOversizedValue(() -> handlerInvoked.set(true)) + .onOversizedSymbolTable(() -> { throw new IonException("Oversized symbol table"); }) + .build(); + + // Use a declared length that fits within maxBuffer + int declaredLength = 200; + byte[] varUInt = encodeVarUInt(declaredLength); + byte[] payload = new byte[4 + 1 + varUInt.length + declaredLength]; + payload[0] = (byte) 0xE0; + payload[1] = 0x01; + payload[2] = 0x00; + payload[3] = (byte) 0xEA; + payload[4] = (byte) 0xAE; + System.arraycopy(varUInt, 0, payload, 5, varUInt.length); + for (int i = 0; i < declaredLength; i++) { + payload[5 + varUInt.length + i] = (byte) (i & 0xFF); + } + + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .withBufferConfiguration(config) + .build(asNonByteArrayInputStream(payload)); + + try { + IonType type = reader.next(); + assertNotNull(type, "Reader should successfully read a value within the buffer limit"); + assertEquals(IonType.BLOB, type, "Value should be a blob"); + assertFalse(handlerInvoked.get(), + "OversizedValueHandler should NOT be invoked when value is within maximumBufferSize"); + } finally { + reader.close(); + } + } + + /** + * Value with declared length one byte over the configured maximum should trigger rejection. + * ensureCapacity() should set isSkippingCurrentValue = true and the oversized handler fires. + */ + @Test + public void valueOneByteOverMaximumBufferSizeTriggersRejection() throws Exception { + final int maxBuffer = 256; + final AtomicBoolean handlerInvoked = new AtomicBoolean(false); + + IonBufferConfiguration config = IonBufferConfiguration.Builder.standard() + .withInitialBufferSize(32) + .withMaximumBufferSize(maxBuffer) + .onOversizedValue(() -> handlerInvoked.set(true)) + .onOversizedSymbolTable(() -> { throw new IonException("Oversized symbol table"); }) + .build(); + + // Declare a length exceeding maxBuffer by 1 + int declaredLength = maxBuffer + 1; + byte[] payload = createBinaryIonWithDeclaredLength(declaredLength); + + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .withBufferConfiguration(config) + .build(asNonByteArrayInputStream(payload)); + + try { + reader.next(); + assertTrue(handlerInvoked.get(), + "OversizedValueHandler should be invoked when declared length exceeds maximumBufferSize by 1 byte"); + } finally { + try { + reader.close(); + } catch (IonException e) { + // Expected: close() may throw "Unexpected EOF" when stream is incomplete after skip + } + } + } + + /** + * Value well within the configured maximum should succeed without any issues. + * This confirms preservation: normal inputs are read correctly. + */ + @Test + public void valueWellWithinMaximumBufferSizeSucceedsNormally() throws Exception { + final int maxBuffer = 1024 * 1024; // 1 MB + final AtomicBoolean handlerInvoked = new AtomicBoolean(false); + + IonBufferConfiguration config = IonBufferConfiguration.Builder.standard() + .withMaximumBufferSize(maxBuffer) + .onOversizedValue(() -> handlerInvoked.set(true)) + .onOversizedSymbolTable(() -> { throw new IonException("Oversized symbol table"); }) + .build(); + + // Small value: a 100-byte blob (well within 1 MB) + int declaredLength = 100; + byte[] varUInt = encodeVarUInt(declaredLength); + byte[] payload = new byte[4 + 1 + varUInt.length + declaredLength]; + payload[0] = (byte) 0xE0; + payload[1] = 0x01; + payload[2] = 0x00; + payload[3] = (byte) 0xEA; + payload[4] = (byte) 0xAE; + System.arraycopy(varUInt, 0, payload, 5, varUInt.length); + for (int i = 0; i < declaredLength; i++) { + payload[5 + varUInt.length + i] = (byte) (i & 0xFF); + } + + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .withBufferConfiguration(config) + .build(asNonByteArrayInputStream(payload)); + + try { + IonType type = reader.next(); + assertNotNull(type, "Reader should successfully read a value well within the buffer limit"); + assertEquals(IonType.BLOB, type, "Value should be a blob"); + assertFalse(handlerInvoked.get(), + "OversizedValueHandler should NOT be invoked for values well within the limit"); + } finally { + reader.close(); + } + } + + /** + * Verifies that the default throwing OversizedValueHandler (used when no custom handler is set) + * throws IonException when the sane default maximumBufferSize is exceeded. + */ + @Test + public void defaultThrowingOversizedValueHandlerIsInvokedForDefaultConfig() throws Exception { + // Declare a length that exceeds the default 100 MB limit + long declaredLength = DEFAULT_MAXIMUM_BUFFER_SIZE + 1000L; + byte[] payload = createBinaryIonWithDeclaredLength(declaredLength); + + // Use the standard builder with NO custom buffer configuration. + // The default throwing handler should fire. + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .build(asNonByteArrayInputStream(payload)); + + try { + assertThrows(IonException.class, () -> { + reader.next(); + }, "Should throw IonException when oversized value encountered with default configuration"); + } finally { + reader.close(); + } + } + + /** + * Verifies that when ensureCapacity() rejects a request, no OutOfMemoryError occurs + * and the OversizedValueHandler is invoked. + */ + @Test + public void rejectedRequestDoesNotAllocateLargeBuffer() throws Exception { + final int maxBuffer = 1024; // 1 KB + final AtomicBoolean handlerInvoked = new AtomicBoolean(false); + + IonBufferConfiguration config = IonBufferConfiguration.Builder.standard() + .withInitialBufferSize(32) + .withMaximumBufferSize(maxBuffer) + .onOversizedValue(() -> handlerInvoked.set(true)) + .onOversizedSymbolTable(() -> { throw new IonException("Oversized symbol table"); }) + .build(); + + // Declare a 10 MB value (way over the 1 KB limit) + long declaredLength = 10 * 1024 * 1024L; + byte[] payload = createBinaryIonWithDeclaredLength(declaredLength); + + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .withBufferConfiguration(config) + .build(asNonByteArrayInputStream(payload)); + + try { + reader.next(); + } finally { + try { + reader.close(); + } catch (IonException e) { + // Expected: close() may throw when stream is incomplete after skip + } + } + + // If we reach here without OOM, the large buffer was not allocated + assertTrue(handlerInvoked.get(), + "OversizedValueHandler should be invoked for the rejected oversized value"); + } + + /** + * Verifies that after the oversized value is skipped, the cursor can continue + * to read subsequent values normally (isSkippingCurrentValue is reset). + */ + @Test + public void cursorContinuesNormallyAfterOversizedValueIsSkipped() throws Exception { + final int maxBuffer = 256; + final AtomicBoolean handlerInvoked = new AtomicBoolean(false); + + IonBufferConfiguration config = IonBufferConfiguration.Builder.standard() + .withInitialBufferSize(32) + .withMaximumBufferSize(maxBuffer) + .onOversizedValue(() -> handlerInvoked.set(true)) + .onOversizedSymbolTable(() -> { throw new IonException("Oversized symbol table"); }) + .build(); + + // Create a stream with: oversized blob (300 bytes), then a small int value (0x21 0x01 = int 1) + int oversizedLength = 300; + byte[] varUInt = encodeVarUInt(oversizedLength); + // Total: IVM(4) + blob_header(1) + varUInt + oversizedLength_data + int_value(2) + byte[] payload = new byte[4 + 1 + varUInt.length + oversizedLength + 2]; + payload[0] = (byte) 0xE0; + payload[1] = 0x01; + payload[2] = 0x00; + payload[3] = (byte) 0xEA; + payload[4] = (byte) 0xAE; // blob with VarUInt length + System.arraycopy(varUInt, 0, payload, 5, varUInt.length); + // Fill blob body with data + int bodyStart = 5 + varUInt.length; + for (int i = 0; i < oversizedLength; i++) { + payload[bodyStart + i] = (byte) (i & 0xFF); + } + // Append a small int value after the blob: 0x21 0x01 (positive int, length 1, value 1) + payload[bodyStart + oversizedLength] = 0x21; + payload[bodyStart + oversizedLength + 1] = 0x01; + + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .withBufferConfiguration(config) + .build(asNonByteArrayInputStream(payload)); + + try { + // First value should be skipped (oversized) + IonType type = reader.next(); + assertTrue(handlerInvoked.get(), + "OversizedValueHandler should have been invoked for the oversized blob"); + + // After skipping oversized value, next value should be readable + IonType secondType = reader.next(); + if (secondType != null) { + assertEquals(IonType.INT, secondType, "Second value should be an int after oversized skip"); + } + } finally { + reader.close(); + } + } +} diff --git a/src/test/java/com/amazon/ion/impl/MemoryExhaustionExplorationTest.java b/src/test/java/com/amazon/ion/impl/MemoryExhaustionExplorationTest.java new file mode 100644 index 000000000..94628c0e8 --- /dev/null +++ b/src/test/java/com/amazon/ion/impl/MemoryExhaustionExplorationTest.java @@ -0,0 +1,265 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package com.amazon.ion.impl; + +import com.amazon.ion.IonBufferConfiguration; +import com.amazon.ion.IonException; +import com.amazon.ion.IonReader; +import com.amazon.ion.IonType; +import com.amazon.ion.system.IonReaderBuilder; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Tests that verify memory-bounded rejection of malicious inputs. + * + * Attack vectors tested: + * - Binary length bomb: VarUInt declares length > 100 MB with only a few actual bytes + * - GZIP bomb: Small compressed stream that decompresses to > 1 GB + * - Text reader unbounded pages: Text Ion exceeding maximumBufferSize without enforcement + */ +@Tag("memory-exhaustion-exploration") +public class MemoryExhaustionExplorationTest { + + /** + * Wraps a byte array to force the refillable stream code path. + */ + private static InputStream asNonByteArrayInputStream(byte[] data) { + return new FilterInputStream(new ByteArrayInputStream(data)) {}; + } + + /** + * Binary length bomb: a ~74-byte payload declares a VarUInt length of 800 MB but contains only + * a few actual data bytes. The reader must reject without allocating proportional memory. + */ + @Test + public void binaryLengthBombShouldNotCauseOOM() { + // Construct a binary Ion payload: IVM + blob type descriptor + VarUInt declaring 800 MB + minimal data + long declaredLength = 800_000_000L; // 800 MB - exceeds default maximum buffer size + byte[] varUInt = encodeVarUInt(declaredLength); + + // Build the payload: IVM + type descriptor (blob with VarUInt length) + VarUInt + minimal data + byte[] payload = new byte[4 + 1 + varUInt.length + 5]; + payload[0] = (byte) 0xE0; // IVM + payload[1] = 0x01; + payload[2] = 0x00; + payload[3] = (byte) 0xEA; + payload[4] = (byte) 0xAE; // blob with VarUInt length + System.arraycopy(varUInt, 0, payload, 5, varUInt.length); + payload[5 + varUInt.length] = 0x00; + payload[6 + varUInt.length] = 0x00; + payload[7 + varUInt.length] = 0x00; + payload[8 + varUInt.length] = 0x00; + payload[9 + varUInt.length] = 0x00; + + try { + // Use a non-ByteArrayInputStream to force the refillable stream code path + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .build(asNonByteArrayInputStream(payload)); + try { + IonType type = reader.next(); + if (type != null) { + reader.getBytes(new byte[10], 0, 10); + } + reader.close(); + } catch (IonException e) { + // Reader detected the malformed/oversized input + reader.close(); + return; + } + } catch (OutOfMemoryError e) { + fail("OutOfMemoryError: ensureCapacity() allocated based on attacker-declared VarUInt length. " + + "Payload: " + payload.length + " bytes declaring " + declaredLength + " bytes."); + } catch (Exception e) { + // Any non-OOM exception means the reader handled it without exhausting memory + } + } + + /** + * Encodes a long value as a VarUInt (variable-length unsigned integer) in Ion binary format. + * In Ion 1.0: each byte contributes 7 data bits; MSB=1 indicates the final byte (stop bit). + */ + private static byte[] encodeVarUInt(long value) { + if (value < 0) { + throw new IllegalArgumentException("VarUInt value must be non-negative"); + } + if (value == 0) { + return new byte[]{(byte) 0x80}; + } + // Determine the number of bytes needed + int numBytes = 0; + long temp = value; + while (temp > 0) { + numBytes++; + temp >>= 7; + } + byte[] result = new byte[numBytes]; + for (int i = numBytes - 1; i >= 0; i--) { + result[i] = (byte) (value & 0x7F); + value >>= 7; + } + // Set the stop bit on the last byte + result[numBytes - 1] |= (byte) 0x80; + return result; + } + + /** + * GZIP bomb: a small compressed stream (~100 KB) that decompresses to > 1 GB. + * The reader must throw IonException before exhausting heap. + */ + @Test + public void gzipBombShouldNotCauseOOM() throws IOException { + byte[] gzipBomb = createGzipBomb(); + + try { + IonReader reader = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true) + .build(asNonByteArrayInputStream(gzipBomb)); + try { + IonType type; + while ((type = reader.next()) != null) { + // consume + } + reader.close(); + } catch (IonException e) { + // Reader detected excessive inflation + reader.close(); + return; + } + } catch (OutOfMemoryError e) { + fail("OutOfMemoryError: GZIP decompression exhausted heap. " + + "Compressed payload: " + gzipBomb.length + " bytes."); + } catch (Exception e) { + // Any non-OOM exception means the reader handled it + } + } + + /** + * Text reader unbounded pages: streams > configured maximumBufferSize through the text reader. + * The reader must throw IonException when accumulated bytes exceed the configured limit. + */ + @Test + public void textReaderShouldHonorMaximumBufferSize() throws IOException { + final int maxBufferSize = 1024 * 1024; // 1 MB + + IonBufferConfiguration bufferConfig = IonBufferConfiguration.Builder.standard() + .withMaximumBufferSize(maxBufferSize) + .onOversizedValue(() -> { + throw new IonException("Oversized value detected"); + }) + .onOversizedSymbolTable(() -> { + throw new IonException("Oversized symbol table detected"); + }) + .build(); + + InputStream largeTextIonStream = new LargeTextIonInputStream(200 * 1024 * 1024); // 200 MB + + try { + IonReader reader = IonReaderBuilder.standard() + .withBufferConfiguration(bufferConfig) + .build(largeTextIonStream); + try { + IonType type; + long valuesRead = 0; + while ((type = reader.next()) != null) { + valuesRead++; + if (valuesRead > 1_000_000) { + break; + } + } + reader.close(); + + if (valuesRead > 1_000_000) { + fail("Text reader did not enforce maximumBufferSize: read >1M values " + + "from 200MB stream with 1MB limit configured."); + } + } catch (IonException e) { + // Reader enforced the buffer limit + reader.close(); + return; + } + } catch (OutOfMemoryError e) { + fail("OutOfMemoryError: UnifiedInputStreamX accumulated pages beyond " + + "configured maximumBufferSize (1 MB)."); + } catch (Exception e) { + // Any non-OOM exception means the reader handled it + } + } + + /** + * Creates a GZIP bomb: binary Ion (IVM + blob declaring 800 MB length) followed by 100 MB + * of zeros, all GZIP-compressed. The compressed output is very small due to the repetitive content. + */ + private static byte[] createGzipBomb() throws IOException { + ByteArrayOutputStream compressedOutput = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOut = new GZIPOutputStream(compressedOutput)) { + // Binary Ion IVM + byte[] ivm = {(byte) 0xE0, 0x01, 0x00, (byte) 0xEA}; + gzipOut.write(ivm); + + // Blob type descriptor with VarUInt length declaring 800 MB + gzipOut.write(0xAE); + byte[] varUInt = encodeVarUInt(800_000_000L); + gzipOut.write(varUInt); + + // Write 100 MB of zeros as body data (compresses extremely well) + byte[] zeros = new byte[64 * 1024]; + long totalWritten = 0; + long targetSize = 100L * 1024 * 1024; + while (totalWritten < targetSize) { + gzipOut.write(zeros); + totalWritten += zeros.length; + } + } + return compressedOutput.toByteArray(); + } + + /** + * An InputStream that generates a stream of repeating valid text Ion integers. + */ + private static class LargeTextIonInputStream extends InputStream { + private static final byte[] ION_VALUE = "12345 ".getBytes(); + private final long totalBytes; + private long bytesRead = 0; + private int posInValue = 0; + + LargeTextIonInputStream(long totalBytes) { + this.totalBytes = totalBytes; + } + + @Override + public int read() { + if (bytesRead >= totalBytes) { + return -1; + } + byte b = ION_VALUE[posInValue]; + posInValue = (posInValue + 1) % ION_VALUE.length; + bytesRead++; + return b & 0xFF; + } + + @Override + public int read(byte[] buf, int off, int len) { + if (bytesRead >= totalBytes) { + return -1; + } + int toRead = (int) Math.min(len, totalBytes - bytesRead); + for (int i = 0; i < toRead; i++) { + buf[off + i] = ION_VALUE[posInValue]; + posInValue = (posInValue + 1) % ION_VALUE.length; + } + bytesRead += toRead; + return toRead; + } + } +} diff --git a/src/test/java/com/amazon/ion/impl/MemoryExhaustionPreservationTest.java b/src/test/java/com/amazon/ion/impl/MemoryExhaustionPreservationTest.java new file mode 100644 index 000000000..15ad34679 --- /dev/null +++ b/src/test/java/com/amazon/ion/impl/MemoryExhaustionPreservationTest.java @@ -0,0 +1,707 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package com.amazon.ion.impl; + +import com.amazon.ion.IonReader; +import com.amazon.ion.IonType; +import com.amazon.ion.IonWriter; +import com.amazon.ion.system.IonBinaryWriterBuilder; +import com.amazon.ion.system.IonReaderBuilder; +import com.amazon.ion.system.IonTextWriterBuilder; +import com.amazon.ion.util.GzipStreamInterceptor; +import com.amazon.ion.util.InputStreamInterceptor; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Preservation tests verifying that normal Ion inputs are processed correctly after the + * memory exhaustion fix. These tests must pass on both unfixed and fixed code. + * + * For all inputs within configured limits (normal value lengths, normal GZIP inflation + * ratios, normal text sizes), the reader must produce correct results. + */ +@Tag("memory-exhaustion-preservation") +public class MemoryExhaustionPreservationTest { + + private static final Random RANDOM = new Random(42); // Fixed seed for reproducibility + + // --- Helper Methods --- + + /** + * Generates a valid binary Ion payload containing random integers. + * The payload size is approximately the target size in bytes. + */ + private static byte[] generateBinaryIonPayload(int approximateTargetSize) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (IonWriter writer = IonBinaryWriterBuilder.standard().build(baos)) { + int bytesWritten = 0; + int valueIndex = 0; + while (bytesWritten < approximateTargetSize) { + // Mix different value types to make the payload realistic + switch (valueIndex % 5) { + case 0: + writer.writeInt(RANDOM.nextInt()); + bytesWritten += 5; // approximate + break; + case 1: + writer.writeString("test_string_" + valueIndex); + bytesWritten += 20; // approximate + break; + case 2: + writer.writeBool(valueIndex % 2 == 0); + bytesWritten += 2; + break; + case 3: + writer.writeFloat(RANDOM.nextDouble()); + bytesWritten += 9; + break; + case 4: + writer.writeNull(); + bytesWritten += 1; + break; + } + valueIndex++; + } + } + return baos.toByteArray(); + } + + /** + * Generates valid text Ion containing random values. + */ + private static byte[] generateTextIonPayload(int approximateTargetSize) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (IonWriter writer = IonTextWriterBuilder.standard().build(baos)) { + int bytesWritten = 0; + int valueIndex = 0; + while (bytesWritten < approximateTargetSize) { + switch (valueIndex % 5) { + case 0: + writer.writeInt(RANDOM.nextInt()); + bytesWritten += 12; + break; + case 1: + writer.writeString("preservation_test_" + valueIndex); + bytesWritten += 30; + break; + case 2: + writer.writeBool(valueIndex % 2 == 0); + bytesWritten += 5; + break; + case 3: + writer.writeFloat(RANDOM.nextDouble()); + bytesWritten += 20; + break; + case 4: + writer.writeNull(); + bytesWritten += 5; + break; + } + valueIndex++; + } + } + return baos.toByteArray(); + } + + /** + * GZIP-compresses the given data. + */ + private static byte[] gzipCompress(byte[] data) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) { + gzipOut.write(data); + } + return baos.toByteArray(); + } + + /** + * Counts all top-level values in the given Ion data using the specified reader builder. + * Returns the list of IonTypes encountered. + */ + private static List readAllValues(IonReaderBuilder builder, byte[] data) throws IOException { + List types = new ArrayList<>(); + try (IonReader reader = builder.build(data)) { + IonType type; + while ((type = reader.next()) != null) { + types.add(type); + } + } + return types; + } + + /** + * Counts all top-level values in the given Ion data using a stream-based reader. + */ + private static List readAllValuesFromStream(IonReaderBuilder builder, byte[] data) throws IOException { + List types = new ArrayList<>(); + try (IonReader reader = builder.build(new ByteArrayInputStream(data))) { + IonType type; + while ((type = reader.next()) != null) { + types.add(type); + } + } + return types; + } + + // --- Property Tests: Binary Ion Preservation --- + + /** + * For valid binary Ion payloads with various sizes within normal bounds, + * the reader produces correct parsed results. + */ + @ParameterizedTest + @ValueSource(ints = {0, 1, 100, 1024, 10240, 102400, 1048576}) + public void binaryIonWithinBoundsReadsCorrectly(int approximateSize) throws IOException { + byte[] binaryIon = generateBinaryIonPayload(approximateSize); + + // Read with standard builder (default configuration) + IonReaderBuilder builder = IonReaderBuilder.standard(); + List types = readAllValues(builder, binaryIon); + + // Verify: all values were read without error + // For size 0, we still get the IVM but no values + if (approximateSize == 0) { + assertTrue(types.isEmpty()); + } else { + assertTrue(types.size() > 0, "Should read at least one value from a non-empty payload"); + } + + // Verify: reading from stream produces same results as reading from bytes + List streamTypes = readAllValuesFromStream(builder, binaryIon); + assertEquals(types, streamTypes, + "Stream-based and byte-based readers should produce identical results"); + } + + /** + * Binary Ion with incremental reading enabled also works correctly for normal payloads. + */ + @ParameterizedTest + @ValueSource(ints = {100, 1024, 10240, 102400}) + public void binaryIonIncrementalReadingPreserved(int approximateSize) throws IOException { + byte[] binaryIon = generateBinaryIonPayload(approximateSize); + + IonReaderBuilder builder = IonReaderBuilder.standard() + .withIncrementalReadingEnabled(true); + + List types = readAllValuesFromStream(builder, binaryIon); + assertTrue(types.size() > 0, "Should read at least one value"); + + // Verify specific value types are present (our generator produces a mix) + assertTrue(types.contains(IonType.INT), "Should contain INT values"); + assertTrue(types.contains(IonType.STRING), "Should contain STRING values"); + } + + // --- Property Tests: GZIP Preservation (Requirements 3.2, 3.4) --- + + /** + * For valid Ion payloads GZIP-compressed with normal inflation ratios (< 10:1), + * decompression + parsing produces correct results. GZIP auto-decompression + * remains enabled by default. + */ + @ParameterizedTest + @ValueSource(ints = {100, 1024, 10240, 102400}) + public void gzipCompressedBinaryIonDecompressesAndParsesCorrectly(int approximateSize) throws IOException { + // Generate binary Ion and compress with GZIP + byte[] binaryIon = generateBinaryIonPayload(approximateSize); + byte[] gzipped = gzipCompress(binaryIon); + + // Verify inflation ratio is reasonable (< 10:1) + double inflationRatio = (double) binaryIon.length / gzipped.length; + assertTrue(inflationRatio < 10.0, + "Test data should have normal inflation ratio, got: " + inflationRatio); + + // Read the compressed data - GZIP auto-decompression should handle it transparently + IonReaderBuilder builder = IonReaderBuilder.standard(); + List compressedTypes = readAllValues(builder, gzipped); + List uncompressedTypes = readAllValues(builder, binaryIon); + + // The compressed and uncompressed reading should produce identical results + assertEquals(uncompressedTypes, compressedTypes, + "GZIP-compressed and uncompressed binary Ion should parse to same result"); + } + + /** + * GZIP-compressed text Ion with normal inflation ratios is auto-decompressed + * and parsed correctly. + */ + @ParameterizedTest + @ValueSource(ints = {100, 1024, 10240, 102400}) + public void gzipCompressedTextIonDecompressesAndParsesCorrectly(int approximateSize) throws IOException { + // Generate text Ion and compress with GZIP + byte[] textIon = generateTextIonPayload(approximateSize); + byte[] gzipped = gzipCompress(textIon); + + // Read the compressed data + IonReaderBuilder builder = IonReaderBuilder.standard(); + List compressedTypes = readAllValues(builder, gzipped); + List uncompressedTypes = readAllValues(builder, textIon); + + assertEquals(uncompressedTypes, compressedTypes, + "GZIP-compressed and uncompressed text Ion should parse to same result"); + } + + /** + * GZIP-compressed Ion read via stream (not byte array) also decompresses correctly. + */ + @Test + public void gzipCompressedIonFromStreamDecompressesCorrectly() throws IOException { + byte[] binaryIon = generateBinaryIonPayload(10240); + byte[] gzipped = gzipCompress(binaryIon); + + IonReaderBuilder builder = IonReaderBuilder.standard(); + List fromBytes = readAllValues(builder, gzipped); + List fromStream = readAllValuesFromStream(builder, gzipped); + + assertEquals(fromBytes, fromStream, + "Stream and byte-array readers should produce identical results for GZIP data"); + } + + // --- Property Tests: Text Ion Preservation --- + + /** + * For valid text Ion documents with sizes within normal bounds, + * parsing produces correct results. + */ + @ParameterizedTest + @ValueSource(ints = {0, 1, 100, 1024, 10240, 102400, 1048576}) + public void textIonWithinBoundsReadsCorrectly(int approximateSize) throws IOException { + byte[] textIon = generateTextIonPayload(approximateSize); + + IonReaderBuilder builder = IonReaderBuilder.standard(); + List types = readAllValues(builder, textIon); + + if (approximateSize == 0) { + assertTrue(types.isEmpty()); + } else { + assertTrue(types.size() > 0, "Should read at least one value"); + } + + // Verify reading from stream produces same results + List streamTypes = readAllValuesFromStream(builder, textIon); + assertEquals(types, streamTypes, + "Stream-based and byte-based text Ion readers should produce identical results"); + } + + /** + * Text Ion with various content types (structs, lists, annotations) parses correctly. + */ + @Test + public void textIonWithComplexStructuresReadsCorrectly() throws IOException { + // Generate text Ion with complex structures + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (IonWriter writer = IonTextWriterBuilder.standard().build(baos)) { + // Write a struct + writer.stepIn(IonType.STRUCT); + writer.setFieldName("name"); + writer.writeString("test"); + writer.setFieldName("value"); + writer.writeInt(42); + writer.setFieldName("nested"); + writer.stepIn(IonType.LIST); + writer.writeInt(1); + writer.writeInt(2); + writer.writeInt(3); + writer.stepOut(); + writer.stepOut(); + + // Write a list + writer.stepIn(IonType.LIST); + writer.writeString("a"); + writer.writeString("b"); + writer.stepOut(); + + // Write annotated values + writer.setTypeAnnotations("annotation1"); + writer.writeSymbol("sym"); + } + byte[] textIon = baos.toByteArray(); + + IonReaderBuilder builder = IonReaderBuilder.standard(); + try (IonReader reader = builder.build(textIon)) { + // Verify struct + assertEquals(IonType.STRUCT, reader.next()); + reader.stepIn(); + assertEquals(IonType.STRING, reader.next()); + assertEquals("name", reader.getFieldName()); + assertEquals("test", reader.stringValue()); + assertEquals(IonType.INT, reader.next()); + assertEquals("value", reader.getFieldName()); + assertEquals(42, reader.intValue()); + assertEquals(IonType.LIST, reader.next()); + assertEquals("nested", reader.getFieldName()); + reader.stepIn(); + assertEquals(IonType.INT, reader.next()); + assertEquals(1, reader.intValue()); + assertEquals(IonType.INT, reader.next()); + assertEquals(2, reader.intValue()); + assertEquals(IonType.INT, reader.next()); + assertEquals(3, reader.intValue()); + assertNull(reader.next()); + reader.stepOut(); + assertNull(reader.next()); + reader.stepOut(); + + // Verify list + assertEquals(IonType.LIST, reader.next()); + reader.stepIn(); + assertEquals(IonType.STRING, reader.next()); + assertEquals("a", reader.stringValue()); + assertEquals(IonType.STRING, reader.next()); + assertEquals("b", reader.stringValue()); + assertNull(reader.next()); + reader.stepOut(); + + // Verify annotated value + assertEquals(IonType.SYMBOL, reader.next()); + String[] annotations = reader.getTypeAnnotations(); + assertEquals(1, annotations.length); + assertEquals("annotation1", annotations[0]); + + assertNull(reader.next()); + } + } + + // --- Property Tests: Interceptor Ordering Preservation --- + + /** + * Custom InputStreamInterceptor instances added via addInputStreamInterceptor() + * are applied in documented order: GZIP first, then detected, then user-added. + */ + @Test + public void interceptorOrderingPreserved() { + // Create custom interceptors + InputStreamInterceptor customInterceptor1 = new TestInterceptor("custom1"); + InputStreamInterceptor customInterceptor2 = new TestInterceptor("custom2"); + + IonReaderBuilder builder = IonReaderBuilder.standard() + .addInputStreamInterceptor(customInterceptor1) + .addInputStreamInterceptor(customInterceptor2); + + List interceptors = builder.getInputStreamInterceptors(); + + // Verify: GZIP is always first + assertSame(GzipStreamInterceptor.INSTANCE, interceptors.get(0), + "GzipStreamInterceptor must always be the first interceptor"); + + // Verify: user-added interceptors come after GZIP (and any detected ones) + // The exact position depends on whether any classpath-detected interceptors exist, + // but custom ones must come at the end in order added + int custom1Index = interceptors.indexOf(customInterceptor1); + int custom2Index = interceptors.indexOf(customInterceptor2); + assertTrue(custom1Index > 0, "Custom interceptor 1 should come after GZIP"); + assertTrue(custom2Index > custom1Index, "Custom interceptor 2 should come after custom 1"); + } + + /** + * Default builder (no custom interceptors) has only GZIP interceptor. + */ + @Test + public void defaultBuilderHasGzipInterceptorFirst() { + IonReaderBuilder builder = IonReaderBuilder.standard(); + List interceptors = builder.getInputStreamInterceptors(); + + // GZIP should be present + assertTrue(interceptors.size() >= 1, "Should have at least one interceptor (GZIP)"); + assertSame(GzipStreamInterceptor.INSTANCE, interceptors.get(0), + "First interceptor must be GzipStreamInterceptor"); + } + + // --- Property Tests: IonReaderBuilder.standard() Preservation (Requirements 3.4, 3.5) --- + + /** + * IonReaderBuilder.standard() with default configuration reads well-formed + * binary Ion without error. + */ + @Test + public void standardBuilderReadsBinaryIonWithoutError() throws IOException { + byte[] binaryIon = generateBinaryIonPayload(10240); + + // Standard builder with no custom configuration + IonReaderBuilder builder = IonReaderBuilder.standard(); + List types = readAllValues(builder, binaryIon); + assertTrue(types.size() > 0); + } + + /** + * IonReaderBuilder.standard() with default configuration reads well-formed + * text Ion without error. + */ + @Test + public void standardBuilderReadsTextIonWithoutError() throws IOException { + byte[] textIon = generateTextIonPayload(10240); + + IonReaderBuilder builder = IonReaderBuilder.standard(); + List types = readAllValues(builder, textIon); + assertTrue(types.size() > 0); + } + + /** + * IonReaderBuilder.standard() auto-detects and decompresses GZIP by default. + */ + @Test + public void standardBuilderAutoDecompressesGzip() throws IOException { + byte[] binaryIon = generateBinaryIonPayload(1024); + byte[] gzipped = gzipCompress(binaryIon); + + IonReaderBuilder builder = IonReaderBuilder.standard(); + + // Should auto-detect GZIP and decompress, then parse binary Ion + List types = readAllValues(builder, gzipped); + assertTrue(types.size() > 0, + "Standard builder should auto-decompress GZIP and read binary Ion"); + } + + /** + * Reading binary Ion data with specific value types preserves exact values. + */ + @Test + public void binaryIonValueIntegrityPreserved() throws IOException { + // Write specific values + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (IonWriter writer = IonBinaryWriterBuilder.standard().build(baos)) { + writer.writeInt(Integer.MAX_VALUE); + writer.writeInt(Integer.MIN_VALUE); + writer.writeInt(0); + writer.writeFloat(3.14159); + writer.writeString("Hello, Ion!"); + writer.writeBool(true); + writer.writeBool(false); + writer.writeNull(); + } + byte[] binaryIon = baos.toByteArray(); + + // Read and verify exact values + IonReaderBuilder builder = IonReaderBuilder.standard(); + try (IonReader reader = builder.build(binaryIon)) { + assertEquals(IonType.INT, reader.next()); + assertEquals(Integer.MAX_VALUE, reader.intValue()); + + assertEquals(IonType.INT, reader.next()); + assertEquals(Integer.MIN_VALUE, reader.intValue()); + + assertEquals(IonType.INT, reader.next()); + assertEquals(0, reader.intValue()); + + assertEquals(IonType.FLOAT, reader.next()); + assertEquals(3.14159, reader.doubleValue(), 0.00001); + + assertEquals(IonType.STRING, reader.next()); + assertEquals("Hello, Ion!", reader.stringValue()); + + assertEquals(IonType.BOOL, reader.next()); + assertTrue(reader.booleanValue()); + + assertEquals(IonType.BOOL, reader.next()); + assertEquals(false, reader.booleanValue()); + + assertEquals(IonType.NULL, reader.next()); + assertTrue(reader.isNullValue()); + + assertNull(reader.next()); + } + } + + /** + * GZIP-compressed binary Ion preserves exact values after decompression. + */ + @Test + public void gzipCompressedBinaryIonPreservesValues() throws IOException { + // Write specific values + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (IonWriter writer = IonBinaryWriterBuilder.standard().build(baos)) { + writer.writeInt(123456789); + writer.writeString("GZIP preserved"); + writer.writeFloat(2.71828); + } + byte[] binaryIon = baos.toByteArray(); + byte[] gzipped = gzipCompress(binaryIon); + + // Read from GZIP-compressed data and verify values + IonReaderBuilder builder = IonReaderBuilder.standard(); + try (IonReader reader = builder.build(gzipped)) { + assertEquals(IonType.INT, reader.next()); + assertEquals(123456789, reader.intValue()); + + assertEquals(IonType.STRING, reader.next()); + assertEquals("GZIP preserved", reader.stringValue()); + + assertEquals(IonType.FLOAT, reader.next()); + assertEquals(2.71828, reader.doubleValue(), 0.00001); + + assertNull(reader.next()); + } + } + + /** + * Text Ion string parsing with various sizes produces correct results. + */ + @Test + public void textIonStringParsingPreserved() throws IOException { + String textIon = "123 \"hello\" true null 3.14e0 [1, 2, 3]"; + + IonReaderBuilder builder = IonReaderBuilder.standard(); + try (IonReader reader = builder.build(textIon)) { + assertEquals(IonType.INT, reader.next()); + assertEquals(123, reader.intValue()); + + assertEquals(IonType.STRING, reader.next()); + assertEquals("hello", reader.stringValue()); + + assertEquals(IonType.BOOL, reader.next()); + assertTrue(reader.booleanValue()); + + assertEquals(IonType.NULL, reader.next()); + assertTrue(reader.isNullValue()); + + assertEquals(IonType.FLOAT, reader.next()); + assertEquals(3.14, reader.doubleValue(), 0.001); + + assertEquals(IonType.LIST, reader.next()); + reader.stepIn(); + assertEquals(IonType.INT, reader.next()); + assertEquals(1, reader.intValue()); + assertEquals(IonType.INT, reader.next()); + assertEquals(2, reader.intValue()); + assertEquals(IonType.INT, reader.next()); + assertEquals(3, reader.intValue()); + assertNull(reader.next()); + reader.stepOut(); + + assertNull(reader.next()); + } + } + + /** + * Verifies GZIP-compressed text Ion with stream-based reader decompresses correctly. + */ + @Test + public void gzipCompressedTextIonFromStreamPreserved() throws IOException { + String ionText = "42 \"gzip_text_test\" true"; + byte[] textBytes = ionText.getBytes(StandardCharsets.UTF_8); + byte[] gzipped = gzipCompress(textBytes); + + IonReaderBuilder builder = IonReaderBuilder.standard(); + try (IonReader reader = builder.build(new ByteArrayInputStream(gzipped))) { + assertEquals(IonType.INT, reader.next()); + assertEquals(42, reader.intValue()); + + assertEquals(IonType.STRING, reader.next()); + assertEquals("gzip_text_test", reader.stringValue()); + + assertEquals(IonType.BOOL, reader.next()); + assertTrue(reader.booleanValue()); + + assertNull(reader.next()); + } + } + + /** + * Property-based: for multiple random binary Ion payloads of different sizes, + * byte-array and stream-based readers produce identical results. + */ + @Test + public void binaryIonConsistencyAcrossReaderModes() throws IOException { + int[] sizes = {64, 256, 1024, 4096, 16384, 65536, 262144}; + IonReaderBuilder builder = IonReaderBuilder.standard(); + + for (int size : sizes) { + byte[] binaryIon = generateBinaryIonPayload(size); + List fromBytes = readAllValues(builder, binaryIon); + List fromStream = readAllValuesFromStream(builder, binaryIon); + assertEquals(fromBytes, fromStream, + "Byte and stream readers should be consistent for size " + size); + assertTrue(fromBytes.size() > 0); + } + } + + /** + * Property-based: for multiple random text Ion payloads of different sizes, + * byte-array and stream-based readers produce identical results. + */ + @Test + public void textIonConsistencyAcrossReaderModes() throws IOException { + int[] sizes = {64, 256, 1024, 4096, 16384, 65536, 262144}; + IonReaderBuilder builder = IonReaderBuilder.standard(); + + for (int size : sizes) { + byte[] textIon = generateTextIonPayload(size); + List fromBytes = readAllValues(builder, textIon); + List fromStream = readAllValuesFromStream(builder, textIon); + assertEquals(fromBytes, fromStream, + "Byte and stream readers should be consistent for text Ion size " + size); + assertTrue(fromBytes.size() > 0); + } + } + + /** + * Property-based: for multiple GZIP-compressed payloads of different sizes, + * decompressed reading produces same result as direct reading. + */ + @Test + public void gzipPreservationAcrossMultipleSizes() throws IOException { + int[] sizes = {64, 256, 1024, 4096, 16384, 65536}; + IonReaderBuilder builder = IonReaderBuilder.standard(); + + for (int size : sizes) { + byte[] binaryIon = generateBinaryIonPayload(size); + byte[] gzipped = gzipCompress(binaryIon); + + List direct = readAllValues(builder, binaryIon); + List fromGzip = readAllValues(builder, gzipped); + + assertEquals(direct, fromGzip, + "GZIP-compressed reading should match direct reading for size " + size); + } + } + + // --- Test Interceptor Implementation --- + + /** + * A simple test interceptor for verifying ordering behavior. + */ + private static class TestInterceptor implements InputStreamInterceptor { + private final String name; + + TestInterceptor(String name) { + this.name = name; + } + + @Override + public String formatName() { + return name; + } + + @Override + public int numberOfBytesNeededToDetermineMatch() { + return 4; + } + + @Override + public boolean isMatch(byte[] candidate, int offset, int length) { + return false; // Never matches - used only for ordering tests + } + + @Override + public InputStream newInputStream(InputStream interceptedStream) { + return interceptedStream; + } + } +} diff --git a/src/test/java/com/amazon/ion/system/IonReaderBuilderGzipToggleTest.java b/src/test/java/com/amazon/ion/system/IonReaderBuilderGzipToggleTest.java new file mode 100644 index 000000000..f289a2597 --- /dev/null +++ b/src/test/java/com/amazon/ion/system/IonReaderBuilderGzipToggleTest.java @@ -0,0 +1,158 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package com.amazon.ion.system; + +import com.amazon.ion.util.GzipStreamInterceptor; +import com.amazon.ion.util.InputStreamInterceptor; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the GZIP decompression toggle API on {@link IonReaderBuilder}. + */ +class IonReaderBuilderGzipToggleTest { + + @Test + void defaultBuilderHasGzipEnabled() { + IonReaderBuilder builder = IonReaderBuilder.standard(); + assertTrue(builder.isGzipDecompressionEnabled()); + } + + @Test + void getInputStreamInterceptorsIncludesGzipWhenEnabled() { + IonReaderBuilder builder = IonReaderBuilder.standard(); + List interceptors = builder.getInputStreamInterceptors(); + boolean hasGzip = interceptors.stream().anyMatch(i -> i instanceof GzipStreamInterceptor); + assertTrue(hasGzip, "Expected GzipStreamInterceptor in interceptor list when GZIP is enabled"); + } + + @Test + void getInputStreamInterceptorsExcludesGzipWhenDisabled() { + IonReaderBuilder builder = IonReaderBuilder.standard() + .withGzipDecompressionEnabled(false); + List interceptors = builder.getInputStreamInterceptors(); + boolean hasGzip = interceptors.stream().anyMatch(i -> i instanceof GzipStreamInterceptor); + assertFalse(hasGzip, "Expected no GzipStreamInterceptor in interceptor list when GZIP is disabled"); + } + + @Test + void customInterceptorsPreservedWhenGzipDisabled() { + InputStreamInterceptor customInterceptor = new InputStreamInterceptor() { + @Override + public String formatName() { + return "custom"; + } + + @Override + public int numberOfBytesNeededToDetermineMatch() { + return 2; + } + + @Override + public boolean isMatch(byte[] candidate, int offset, int length) { + return false; + } + + @Override + public InputStream newInputStream(InputStream interceptedStream) throws IOException { + return interceptedStream; + } + }; + + IonReaderBuilder builder = IonReaderBuilder.standard() + .addInputStreamInterceptor(customInterceptor) + .withGzipDecompressionEnabled(false); + + List interceptors = builder.getInputStreamInterceptors(); + + // Custom interceptor should still be present + assertTrue(interceptors.contains(customInterceptor), + "Custom interceptor should be preserved when GZIP is disabled"); + + // GZIP interceptor should be excluded + boolean hasGzip = interceptors.stream().anyMatch(i -> i instanceof GzipStreamInterceptor); + assertFalse(hasGzip, "GzipStreamInterceptor should be excluded when GZIP is disabled"); + } + + @Test + void customInterceptorsPreservedWhenGzipEnabled() { + InputStreamInterceptor customInterceptor = new InputStreamInterceptor() { + @Override + public String formatName() { + return "custom"; + } + + @Override + public int numberOfBytesNeededToDetermineMatch() { + return 2; + } + + @Override + public boolean isMatch(byte[] candidate, int offset, int length) { + return false; + } + + @Override + public InputStream newInputStream(InputStream interceptedStream) throws IOException { + return interceptedStream; + } + }; + + IonReaderBuilder builder = IonReaderBuilder.standard() + .addInputStreamInterceptor(customInterceptor) + .withGzipDecompressionEnabled(true); + + List interceptors = builder.getInputStreamInterceptors(); + + // Custom interceptor should be present + assertTrue(interceptors.contains(customInterceptor), + "Custom interceptor should be preserved when GZIP is enabled"); + + // GZIP interceptor should also be present + boolean hasGzip = interceptors.stream().anyMatch(i -> i instanceof GzipStreamInterceptor); + assertTrue(hasGzip, "GzipStreamInterceptor should be present when GZIP is enabled"); + } + + @Test + void withGzipDecompressionEnabledOnImmutableBuilderReturnsMutableCopy() { + IonReaderBuilder immutableBuilder = IonReaderBuilder.standard().immutable(); + IonReaderBuilder result = immutableBuilder.withGzipDecompressionEnabled(false); + + // The result should be a different instance (mutable copy) + assertNotSame(immutableBuilder, result); + // The original should still have GZIP enabled + assertTrue(immutableBuilder.isGzipDecompressionEnabled()); + // The new builder should have GZIP disabled + assertFalse(result.isGzipDecompressionEnabled()); + } + + @Test + void copyConstructorPreservesGzipFlag() { + IonReaderBuilder original = IonReaderBuilder.standard() + .withGzipDecompressionEnabled(false); + IonReaderBuilder copy = original.copy(); + + assertFalse(copy.isGzipDecompressionEnabled(), + "Copy should preserve the gzipDecompressionEnabled flag"); + } + + @Test + void setGzipDecompressionEnabledOnMutableBuilder() { + IonReaderBuilder builder = IonReaderBuilder.standard(); + builder.setGzipDecompressionEnabled(false); + assertFalse(builder.isGzipDecompressionEnabled()); + } + + @Test + void setGzipDecompressionEnabledOnImmutableBuilderThrows() { + IonReaderBuilder immutableBuilder = IonReaderBuilder.standard().immutable(); + assertThrows(UnsupportedOperationException.class, () -> { + immutableBuilder.setGzipDecompressionEnabled(false); + }); + } +}