001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.util.Optional;
025
026import static java.lang.String.format;
027
028/**
029 * Policy used by the standard HTTP server to decide whether and how gzip-compressed request bodies
030 * ({@code Content-Encoding: gzip} or {@code x-gzip}, per
031 * <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-8.4">RFC 9110, Section 8.4</a>) are
032 * transparently decompressed before request handling. Only a single {@code gzip}/{@code x-gzip} coding is
033 * supported; coding chains (e.g. {@code identity, gzip}) are rejected with {@code 415} as sanctioned by
034 * <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.16">RFC 9110, Section 15.5.16</a>.
035 * <p>
036 * Decompression is <strong>opt-in</strong> and disabled by default; see
037 * {@code HttpServer.Builder#requestDecompressionPolicy(RequestDecompressionPolicy)}. When disabled, request
038 * bodies are passed to handlers exactly as received, compressed or not.
039 * <p>
040 * When enabled:
041 * <ul>
042 * <li>A request with {@code Content-Encoding: gzip} or {@code x-gzip} has its body decompressed; the
043 * {@code Content-Encoding} header is removed and {@code Content-Length} is updated to the decompressed size,
044 * so handlers observe a self-consistent uncompressed request. The original encoded payload size remains
045 * available through {@link Request#getEncodedBodySizeInBytes()} for telemetry and diagnostics.</li>
046 * <li>A request with an unsupported {@code Content-Encoding} (or multiple codings) is rejected with
047 * {@code 415 Unsupported Media Type}.</li>
048 * <li>A request whose body cannot be decompressed is rejected with {@code 400 Bad Request}.</li>
049 * <li>A request whose decompressed body exceeds {@link #getMaximumDecompressedBodySizeInBytes()} (or, when
050 * unset, the server's {@code maximumRequestSizeInBytes}) or expands beyond
051 * {@link #getMaximumCompressionRatio()} is rejected through the normal {@code 413 Content Too Large}
052 * marshaling path. These limits guard against decompression bombs; decompression aborts as soon as a
053 * limit is exceeded.</li>
054 * <li>{@code Content-Encoding: identity}, requests without {@code Content-Encoding}, and bodyless
055 * {@code gzip}/{@code x-gzip} requests are passed through unchanged.</li>
056 * </ul>
057 * <p>
058 * This applies to the standard HTTP server only. The SSE and MCP servers do not decompress request bodies,
059 * and the {@code Simulator} exercises handlers directly without transport-level decompression.
060 *
061 * @author <a href="https://www.revetkn.com">Mark Allen</a>
062 */
063@ThreadSafe
064public final class RequestDecompressionPolicy {
065        @NonNull
066        private static final Integer DEFAULT_MAXIMUM_COMPRESSION_RATIO;
067        @NonNull
068        private static final RequestDecompressionPolicy DISABLED_INSTANCE;
069        @NonNull
070        private static final RequestDecompressionPolicy DEFAULTS_INSTANCE;
071
072        static {
073                DEFAULT_MAXIMUM_COMPRESSION_RATIO = 100;
074                DISABLED_INSTANCE = new RequestDecompressionPolicy(false, null, DEFAULT_MAXIMUM_COMPRESSION_RATIO);
075                DEFAULTS_INSTANCE = new RequestDecompressionPolicy(true, null, DEFAULT_MAXIMUM_COMPRESSION_RATIO);
076        }
077
078        @NonNull
079        private final Boolean enabled;
080        @Nullable
081        private final Integer maximumDecompressedBodySizeInBytes;
082        @NonNull
083        private final Integer maximumCompressionRatio;
084
085        private RequestDecompressionPolicy(@NonNull Boolean enabled,
086                                                                                                                                                 @Nullable Integer maximumDecompressedBodySizeInBytes,
087                                                                                                                                                 @NonNull Integer maximumCompressionRatio) {
088                this.enabled = enabled;
089                this.maximumDecompressedBodySizeInBytes = maximumDecompressedBodySizeInBytes;
090                this.maximumCompressionRatio = maximumCompressionRatio;
091        }
092
093        /**
094         * Acquires a policy that disables request decompression (the default): request bodies are passed to
095         * handlers exactly as received.
096         *
097         * @return a disabled request decompression policy
098         */
099        @NonNull
100        public static RequestDecompressionPolicy disabledInstance() {
101                return DISABLED_INSTANCE;
102        }
103
104        /**
105         * Acquires a policy that enables gzip request decompression with default limits: the decompressed body is
106         * capped by the server's {@code maximumRequestSizeInBytes} and a {@code 100:1} compression ratio.
107         *
108         * @return a default request decompression policy
109         */
110        @NonNull
111        public static RequestDecompressionPolicy fromDefaults() {
112                return DEFAULTS_INSTANCE;
113        }
114
115        /**
116         * Acquires a builder for an enabled request decompression policy with custom limits.
117         *
118         * @return a request decompression policy builder
119         */
120        @NonNull
121        public static Builder builder() {
122                return new Builder();
123        }
124
125        /**
126         * Is request decompression enabled?
127         *
128         * @return {@code true} if gzip request bodies should be decompressed, {@code false} otherwise
129         */
130        @NonNull
131        public Boolean isEnabled() {
132                return this.enabled;
133        }
134
135        /**
136         * The maximum permitted decompressed body size in bytes, if customized.
137         * <p>
138         * When empty, the server's {@code maximumRequestSizeInBytes} applies to the decompressed body.
139         *
140         * @return the maximum decompressed body size, or {@link Optional#empty()} to use the server's request size limit
141         */
142        @NonNull
143        public Optional<Integer> getMaximumDecompressedBodySizeInBytes() {
144                return Optional.ofNullable(this.maximumDecompressedBodySizeInBytes);
145        }
146
147        /**
148         * The maximum permitted expansion ratio between decompressed and compressed body sizes.
149         * <p>
150         * A request is rejected with {@code 413 Content Too Large} once its decompressed size exceeds
151         * {@code (compressed size × ratio) + 8 KB}; the additive allowance keeps legitimately small compressed
152         * bodies from tripping the ratio check.
153         *
154         * @return the maximum compression ratio (default {@code 100})
155         */
156        @NonNull
157        public Integer getMaximumCompressionRatio() {
158                return this.maximumCompressionRatio;
159        }
160
161        @Override
162        @NonNull
163        public String toString() {
164                return format("%s{enabled=%s, maximumDecompressedBodySizeInBytes=%s, maximumCompressionRatio=%s}",
165                                getClass().getSimpleName(), isEnabled(), getMaximumDecompressedBodySizeInBytes().orElse(null),
166                                getMaximumCompressionRatio());
167        }
168
169        /**
170         * Builder for enabled {@link RequestDecompressionPolicy} instances.
171         */
172        @NotThreadSafe
173        public static final class Builder {
174                @Nullable
175                private Integer maximumDecompressedBodySizeInBytes;
176                @Nullable
177                private Integer maximumCompressionRatio;
178
179                private Builder() {}
180
181                /**
182                 * Sets the maximum permitted decompressed body size in bytes, or {@code null} to use the server's
183                 * {@code maximumRequestSizeInBytes}.
184                 *
185                 * @param maximumDecompressedBodySizeInBytes the maximum decompressed body size, or {@code null} for the server default
186                 * @return this builder
187                 */
188                @NonNull
189                public Builder maximumDecompressedBodySizeInBytes(@Nullable Integer maximumDecompressedBodySizeInBytes) {
190                        this.maximumDecompressedBodySizeInBytes = maximumDecompressedBodySizeInBytes;
191                        return this;
192                }
193
194                /**
195                 * Sets the maximum permitted expansion ratio between decompressed and compressed body sizes, or
196                 * {@code null} for the default of {@code 100}. Must be between {@code 1} and {@code 10_000};
197                 * the upper bound keeps ratio arithmetic safely within {@code long} range for any legal body size
198                 * (gzip's theoretical maximum expansion is roughly {@code 1032:1}).
199                 *
200                 * @param maximumCompressionRatio the maximum compression ratio, or {@code null} for the default
201                 * @return this builder
202                 */
203                @NonNull
204                public Builder maximumCompressionRatio(@Nullable Integer maximumCompressionRatio) {
205                        this.maximumCompressionRatio = maximumCompressionRatio;
206                        return this;
207                }
208
209                /**
210                 * Builds an enabled {@link RequestDecompressionPolicy}.
211                 *
212                 * @return the request decompression policy
213                 * @throws IllegalArgumentException if a limit is zero or negative
214                 */
215                @NonNull
216                public RequestDecompressionPolicy build() {
217                        if (this.maximumDecompressedBodySizeInBytes != null && this.maximumDecompressedBodySizeInBytes <= 0)
218                                throw new IllegalArgumentException(format("maximumDecompressedBodySizeInBytes must be positive, was %d",
219                                                this.maximumDecompressedBodySizeInBytes));
220
221                        if (this.maximumCompressionRatio != null
222                                        && (this.maximumCompressionRatio <= 0 || this.maximumCompressionRatio > 10_000))
223                                throw new IllegalArgumentException(format("maximumCompressionRatio must be between 1 and 10000, was %d",
224                                                this.maximumCompressionRatio));
225
226                        return new RequestDecompressionPolicy(true, this.maximumDecompressedBodySizeInBytes,
227                                        this.maximumCompressionRatio != null ? this.maximumCompressionRatio : DEFAULT_MAXIMUM_COMPRESSION_RATIO);
228                }
229        }
230}