diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/async/methods/InflatingDictionaryBrotliDataConsumer.java b/httpclient5/src/main/java/org/apache/hc/client5/http/async/methods/InflatingDictionaryBrotliDataConsumer.java
new file mode 100644
index 0000000000..0fb9fc8155
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/async/methods/InflatingDictionaryBrotliDataConsumer.java
@@ -0,0 +1,321 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.async.methods;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+import com.aayushatharva.brotli4j.decoder.DecoderJNI;
+
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * {@link AsyncDataConsumer} that decodes a Dictionary-Compressed Brotli ({@code dcb}) response
+ * on the fly and forwards the plain output to a downstream consumer.
+ *
+ * A {@code dcb} stream begins with a fixed header: the four-byte magic sequence
+ * {@code 0xFF 0x44 0x43 0x42} followed by the 32-byte SHA-256 hash of the dictionary the origin
+ * used to compress the body. The header is buffered until complete and checked against the
+ * supplied {@link CompressionDictionary}, which is attached to the Brotli decoder as the shared
+ * dictionary before any compressed payload is
+ * decoded. The header hash must identify the exact dictionary negotiated for this exchange;
+ * otherwise the stream is rejected. See
+ * the Compression Dictionary Transport specification for the dictionary negotiation and framing.
+ *
+ * This consumer is stateful and not thread-safe; a fresh instance is required per response.
+ *
+ * @since 5.7
+ */
+public final class InflatingDictionaryBrotliDataConsumer implements AsyncDataConsumer {
+
+ private static final byte[] MAGIC = {
+ (byte) 0xff, 0x44, 0x43, 0x42
+ };
+
+ private static final int HASH_LENGTH = 32;
+ private static final int HEADER_LENGTH = MAGIC.length + HASH_LENGTH;
+
+ private final AsyncDataConsumer downstream;
+ private final CompressionDictionary dictionary;
+ private final ByteBuffer header;
+
+ private DecoderJNI.Wrapper decoder;
+ private ByteBuffer pendingOutput;
+
+ /**
+ * Creates a consumer that decodes a {@code dcb} stream and forwards the decoded bytes to
+ * {@code downstream}.
+ *
+ * @param downstream the consumer that receives the decompressed content; must not be {@code null}.
+ * @param dictionary the exact dictionary advertised for this exchange; must not be {@code null}.
+ * @since 5.7
+ */
+ public InflatingDictionaryBrotliDataConsumer(
+ final AsyncDataConsumer downstream,
+ final CompressionDictionary dictionary) {
+ this.downstream = Args.notNull(downstream, "Downstream data consumer");
+ this.dictionary = Args.notNull(dictionary, "Compression dictionary");
+ this.header = ByteBuffer.allocate(HEADER_LENGTH);
+ }
+
+ /**
+ * Propagates capacity signalling to the transport. The downstream consumer's demand is relayed
+ * through a wrapper so that back-pressure it exerts on the decoded output is translated into
+ * capacity requests on the compressed input.
+ *
+ * @since 5.7
+ */
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ downstream.updateCapacity(new InflatingCapacityChannel(capacityChannel));
+ }
+
+ /**
+ * Consumes a chunk of the compressed stream. Until the fixed header has been fully buffered and
+ * the dictionary resolved, incoming bytes feed header parsing and no output is produced; once the
+ * decoder is initialised the chunk is handed to it and any decoded bytes are pushed downstream.
+ * A single call may be a partial header, may straddle the header and payload boundary, or may be
+ * pure payload.
+ *
+ * @param src the next slice of compressed bytes; fully drained on return.
+ * @throws IOException if the header is malformed, the dictionary is unavailable, the stream is
+ * corrupt, or trailing bytes follow a completed stream.
+ * @since 5.7
+ */
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ if (decoder == null) {
+ consumeHeader(src);
+ if (decoder == null) {
+ return;
+ }
+ }
+ if (!drainPendingOutput()) {
+ return;
+ }
+
+ while (src.hasRemaining()) {
+ if (decoder.getStatus() == DecoderJNI.Status.DONE) {
+ throw new IOException("Unexpected data after DCB stream");
+ }
+
+ final ByteBuffer in = decoder.getInputBuffer();
+ in.clear();
+
+ final int xfer = Math.min(src.remaining(), in.remaining());
+ final int lim = src.limit();
+ src.limit(src.position() + xfer);
+ in.put(src);
+ src.limit(lim);
+
+ decoder.push(xfer);
+ pump();
+ }
+ }
+
+ private void consumeHeader(final ByteBuffer src) throws IOException {
+ final int xfer = Math.min(src.remaining(), header.remaining());
+ final int lim = src.limit();
+ src.limit(src.position() + xfer);
+ header.put(src);
+ src.limit(lim);
+
+ if (header.hasRemaining()) {
+ return;
+ }
+
+ header.flip();
+
+ for (final byte expected : MAGIC) {
+ if (header.get() != expected) {
+ throw new IOException("Invalid DCB stream header");
+ }
+ }
+
+ final byte[] hash = new byte[HASH_LENGTH];
+ header.get(hash);
+
+ if (!dictionary.matchesHash(hash)) {
+ throw new IOException("DCB stream does not use the negotiated dictionary");
+ }
+
+ try {
+ decoder = new DecoderJNI.Wrapper(8 * 1024);
+
+ final byte[] content = dictionary.getContent();
+ final ByteBuffer dictionaryBuffer = ByteBuffer.allocateDirect(content.length);
+ dictionaryBuffer.put(content);
+ dictionaryBuffer.flip();
+
+ if (!decoder.attachDictionary(dictionaryBuffer)) {
+ decoder.destroy();
+ decoder = null;
+ throw new IOException("Unable to attach Brotli dictionary");
+ }
+ } catch (final IOException ex) {
+ throw ex;
+ } catch (final RuntimeException ex) {
+ throw new IOException("Unable to initialize DCB decoder", ex);
+ }
+ }
+
+ private void pump() throws IOException {
+ if (!drainPendingOutput()) {
+ return;
+ }
+ for (; ; ) {
+ switch (decoder.getStatus()) {
+ case OK:
+ decoder.push(0);
+ break;
+ case NEEDS_MORE_OUTPUT: {
+ final ByteBuffer nativeBuf = decoder.pull();
+ if (!deliver(nativeBuf)) {
+ return;
+ }
+ break;
+ }
+ case NEEDS_MORE_INPUT:
+ if (decoder.hasOutput()) {
+ final ByteBuffer nativeBuf = decoder.pull();
+ if (nativeBuf != null && nativeBuf.hasRemaining()) {
+ if (!deliver(nativeBuf)) {
+ return;
+ }
+ break;
+ }
+ }
+ return;
+ case DONE:
+ if (decoder.hasOutput()) {
+ final ByteBuffer nativeBuf = decoder.pull();
+ if (nativeBuf != null && nativeBuf.hasRemaining()) {
+ if (!deliver(nativeBuf)) {
+ return;
+ }
+ break;
+ }
+ }
+ return;
+ default:
+ throw new IOException("DCB stream corrupted");
+ }
+ }
+ }
+
+ private boolean deliver(final ByteBuffer source) throws IOException {
+ if (source != null && source.hasRemaining()) {
+ pendingOutput = ByteBuffer.allocateDirect(source.remaining());
+ pendingOutput.put(source).flip();
+ }
+ return drainPendingOutput();
+ }
+
+ private boolean drainPendingOutput() throws IOException {
+ if (pendingOutput != null) {
+ downstream.consume(pendingOutput);
+ if (!pendingOutput.hasRemaining()) {
+ pendingOutput = null;
+ }
+ }
+ return pendingOutput == null;
+ }
+
+ private void drainDecoderAtEnd() throws IOException {
+ do {
+ pump();
+ while (pendingOutput != null) {
+ final int position = pendingOutput.position();
+ drainPendingOutput();
+ if (pendingOutput != null && pendingOutput.position() == position) {
+ throw new IOException("Unable to deliver decoded DCB data");
+ }
+ }
+ } while (decoder.getStatus() == DecoderJNI.Status.NEEDS_MORE_OUTPUT
+ || decoder.hasOutput());
+ }
+
+ /**
+ * Finalises decoding at end of stream. The decoder is drained of any pending output and the
+ * completion is signalled to the downstream consumer. A stream that ends before the header is
+ * complete, or before the Brotli decoder reaches its terminal state, is treated as truncated.
+ *
+ * @param trailers the response trailers, forwarded unchanged to the downstream consumer.
+ * @throws IOException if the stream ends prematurely or the decoder has not fully consumed it.
+ * @since 5.7
+ */
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws IOException, HttpException {
+ if (header.hasRemaining() || decoder == null) {
+ throw new IOException("Truncated DCB stream header");
+ }
+
+ drainDecoderAtEnd();
+
+ if (decoder.getStatus() == DecoderJNI.Status.NEEDS_MORE_INPUT) {
+ try {
+ decoder.push(0);
+ drainDecoderAtEnd();
+ } catch (final RuntimeException ex) {
+ throw new IOException("DCB stream corrupted", ex);
+ }
+ }
+
+ if (decoder.getStatus() != DecoderJNI.Status.DONE) {
+ throw new IOException("Truncated DCB stream");
+ }
+
+ downstream.streamEnd(trailers);
+ }
+
+ /**
+ * Releases the native Brotli decoder and propagates the call to the downstream consumer. Safe to
+ * invoke whether or not the decoder was ever initialised; failures while destroying the native
+ * decoder are suppressed so that downstream release always runs.
+ *
+ * @since 5.7
+ */
+ @Override
+ public void releaseResources() {
+ if (decoder != null) {
+ try {
+ decoder.destroy();
+ } catch (final Throwable ignore) {
+ } finally {
+ decoder = null;
+ }
+ }
+ pendingOutput = null;
+ downstream.releaseResources();
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/async/methods/InflatingDictionaryZstdDataConsumer.java b/httpclient5/src/main/java/org/apache/hc/client5/http/async/methods/InflatingDictionaryZstdDataConsumer.java
new file mode 100644
index 0000000000..adf7af8517
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/async/methods/InflatingDictionaryZstdDataConsumer.java
@@ -0,0 +1,282 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.async.methods;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import com.github.luben.zstd.ZstdDecompressCtx;
+
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * {@link AsyncDataConsumer} that inflates a Dictionary-Compressed Zstandard ({@code dcz})
+ * response body on the fly and forwards the decoded bytes to a downstream consumer.
+ *
+ * The stream opens with the {@code dcz} framing prefix defined by Compression Dictionary Transport: an eight-byte
+ * magic sequence followed by the 32-byte hash of the dictionary the origin used to compress
+ * the body. The hash is checked against the supplied {@link CompressionDictionary}, which is
+ * loaded into the Zstandard decompression context
+ * before any payload is decoded. If the prefix is malformed, or no dictionary matching the
+ * advertised hash is available, decoding fails with an {@link IOException} so the exchange is
+ * not silently served undecoded content.
+ *
+ * Input arrives incrementally and is decoded in bounded direct buffers; output is delivered to
+ * the downstream consumer as it becomes available, honouring back-pressure by returning early
+ * whenever the downstream cannot accept the whole batch. Instances are single-use and not
+ * thread-safe: they follow the sequential {@code AsyncDataConsumer} callback contract.
+ *
+ * @since 5.7
+ */
+public final class InflatingDictionaryZstdDataConsumer implements AsyncDataConsumer {
+
+ private static final byte[] MAGIC = {
+ 0x5e, 0x2a, 0x4d, 0x18, 0x20, 0x00, 0x00, 0x00
+ };
+
+ private static final int HASH_LENGTH = 32;
+ private static final int HEADER_LENGTH = MAGIC.length + HASH_LENGTH;
+
+ private static final int IN_BUF = 64 * 1024;
+ private static final int OUT_BUF = 128 * 1024;
+
+ private final AsyncDataConsumer downstream;
+ private final CompressionDictionary dictionary;
+ private final ByteBuffer header;
+ private final ZstdDecompressCtx dctx;
+ private final ByteBuffer inDirect;
+ private final ByteBuffer outDirect;
+ private final AtomicBoolean closed;
+
+ private boolean initialized;
+ private boolean frameComplete;
+
+ /**
+ * Creates a consumer that decodes a {@code dcz} body and forwards the inflated bytes.
+ *
+ * @param downstream the consumer that receives the decoded content; must not be {@code null}.
+ * @param dictionary the exact dictionary advertised for this exchange; must not be {@code null}.
+ * @since 5.7
+ */
+ public InflatingDictionaryZstdDataConsumer(
+ final AsyncDataConsumer downstream,
+ final CompressionDictionary dictionary) {
+ this.downstream = Args.notNull(downstream, "Downstream data consumer");
+ this.dictionary = Args.notNull(dictionary, "Compression dictionary");
+ this.header = ByteBuffer.allocate(HEADER_LENGTH);
+ this.dctx = new ZstdDecompressCtx();
+ this.inDirect = ByteBuffer.allocateDirect(IN_BUF);
+ this.outDirect = ByteBuffer.allocateDirect(OUT_BUF);
+ this.closed = new AtomicBoolean(false);
+
+ inDirect.limit(0);
+ outDirect.limit(0);
+ }
+
+ /**
+ * Propagates a capacity update to the downstream consumer. The channel is wrapped so that the
+ * capacity requested reflects compressed input rather than the larger inflated output, keeping
+ * back-pressure meaningful across the decoding step.
+ *
+ * @since 5.7
+ */
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ downstream.updateCapacity(new InflatingCapacityChannel(capacityChannel));
+ }
+
+ /**
+ * Decodes the next chunk of the {@code dcz} body. The framing prefix is accumulated and
+ * validated across the leading calls; once the dictionary has been resolved and loaded, the
+ * remaining bytes are inflated and passed downstream. Calls made after the stream has ended or
+ * its resources have been released are ignored.
+ *
+ * @throws IOException if the framing prefix is invalid, the required dictionary is not
+ * available, or the compressed stream is corrupt.
+ * @since 5.7
+ */
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ if (closed.get()) {
+ return;
+ }
+
+ if (!initialized) {
+ consumeHeader(src);
+ if (!initialized) {
+ return;
+ }
+ }
+
+ try {
+ while (src.hasRemaining()) {
+ inDirect.compact();
+
+ final int take = Math.min(inDirect.remaining(), src.remaining());
+ final int oldLimit = src.limit();
+ src.limit(src.position() + take);
+ inDirect.put(src);
+ src.limit(oldLimit);
+
+ inDirect.flip();
+
+ while (inDirect.hasRemaining()) {
+ outDirect.compact();
+
+ frameComplete =
+ dctx.decompressDirectByteBufferStream(outDirect, inDirect);
+
+ outDirect.flip();
+
+ if (outDirect.hasRemaining()) {
+ downstream.consume(outDirect);
+ if (outDirect.hasRemaining()) {
+ return;
+ }
+ } else if (!inDirect.hasRemaining()) {
+ break;
+ }
+ }
+ }
+ } catch (final RuntimeException ex) {
+ throw new IOException("DCZ stream corrupted", ex);
+ }
+ }
+
+ private void consumeHeader(final ByteBuffer src) throws IOException {
+ final int xfer = Math.min(src.remaining(), header.remaining());
+ final int lim = src.limit();
+ src.limit(src.position() + xfer);
+ header.put(src);
+ src.limit(lim);
+
+ if (header.hasRemaining()) {
+ return;
+ }
+
+ header.flip();
+
+ for (final byte expected : MAGIC) {
+ if (header.get() != expected) {
+ throw new IOException("Invalid DCZ stream header");
+ }
+ }
+
+ final byte[] hash = new byte[HASH_LENGTH];
+ header.get(hash);
+
+ if (!dictionary.matchesHash(hash)) {
+ throw new IOException("DCZ stream does not use the negotiated dictionary");
+ }
+
+ try {
+ dctx.loadDict(dictionary.getContent());
+ initialized = true;
+ } catch (final RuntimeException ex) {
+ throw new IOException("Unable to initialize DCZ decoder", ex);
+ }
+ }
+
+ private void finishBufferedInput() throws IOException {
+ try {
+ drainOutput();
+ while (inDirect.hasRemaining()) {
+ final int inputPosition = inDirect.position();
+ outDirect.compact();
+ frameComplete = dctx.decompressDirectByteBufferStream(outDirect, inDirect);
+ outDirect.flip();
+ drainOutput();
+ if (inDirect.position() == inputPosition) {
+ throw new IOException("DCZ stream made no progress");
+ }
+ }
+ } catch (final IOException ex) {
+ throw ex;
+ } catch (final RuntimeException ex) {
+ throw new IOException("DCZ stream corrupted", ex);
+ }
+ }
+
+ private void drainOutput() throws IOException {
+ while (outDirect.hasRemaining()) {
+ final int position = outDirect.position();
+ downstream.consume(outDirect);
+ if (outDirect.position() == position) {
+ throw new IOException("Unable to deliver decoded DCZ data");
+ }
+ }
+ }
+
+ /**
+ * Finalises decoding once the origin has signalled end of stream. The header and the final
+ * Zstandard frame must both be complete; an incomplete header or a truncated frame is reported
+ * as an {@link IOException} rather than yielding partial content. On success the decompression
+ * context is closed and {@code streamEnd} is propagated downstream exactly once.
+ *
+ * @throws IOException if the {@code dcz} header or the compressed frame was truncated.
+ * @since 5.7
+ */
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ if (!initialized) {
+ throw new IOException("Truncated DCZ stream header");
+ }
+
+ finishBufferedInput();
+
+ if (!frameComplete) {
+ throw new IOException("Truncated DCZ stream");
+ }
+
+ if (closed.compareAndSet(false, true)) {
+ dctx.close();
+ downstream.streamEnd(trailers);
+ }
+ }
+
+ /**
+ * Releases the native Zstandard decompression context, if it has not already been closed by
+ * {@link #streamEnd(List)}, and releases the downstream consumer. Safe to call more than once
+ * and safe to call to abort an in-flight exchange.
+ *
+ * @since 5.7
+ */
+ @Override
+ public void releaseResources() {
+ if (closed.compareAndSet(false, true)) {
+ dctx.close();
+ }
+ downstream.releaseResources();
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/BasicCompressionDictionaryStore.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/BasicCompressionDictionaryStore.java
new file mode 100644
index 0000000000..4c2ec3a3d0
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/BasicCompressionDictionaryStore.java
@@ -0,0 +1,302 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.entity.compress;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.apache.hc.client5.http.cookie.CookieStore;
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Basic in-memory implementation of {@link CompressionDictionaryStore}.
+ *
+ * Dictionaries are held in a bounded map keyed by cookie partition, request origin and the SHA-256
+ * hash of the dictionary content, so identical content stored in different privacy partitions or
+ * against different origins yields distinct entries. The origin is normalised to its scheme, host
+ * and effective port, {@code 443} being assumed for {@code https} when no explicit port is present. The
+ * store retains at most {@code maxEntries} dictionaries; once that bound is exceeded the eldest
+ * entries are evicted in insertion order until the store is back within capacity. Adding a
+ * dictionary whose key already exists replaces the previous entry and moves it to the
+ * most-recently-added position, so it is evicted last. All access is guarded by a
+ * {@link ReentrantLock}, hence instances are safe for concurrent use.
+ *
+ * @since 5.7
+ */
+@Contract(threading = ThreadingBehavior.SAFE)
+public final class BasicCompressionDictionaryStore implements CompressionDictionaryStore {
+
+ private static final int DEFAULT_MAX_ENTRIES = 64;
+
+ private final int maxEntries;
+ private final Map dictionaries;
+ private final ReentrantLock lock;
+
+ /**
+ * Creates a store bounded to the default of {@value #DEFAULT_MAX_ENTRIES} dictionaries.
+ *
+ * @since 5.7
+ */
+ public BasicCompressionDictionaryStore() {
+ this(DEFAULT_MAX_ENTRIES);
+ }
+
+ /**
+ * Creates a store bounded to the given number of dictionaries.
+ *
+ * @param maxEntries the maximum number of dictionaries to retain; must be positive.
+ * @throws IllegalArgumentException if {@code maxEntries} is not positive.
+ * @since 5.7
+ */
+ public BasicCompressionDictionaryStore(final int maxEntries) {
+ this.maxEntries = Args.positive(maxEntries, "Maximum entries");
+ this.dictionaries = new LinkedHashMap<>();
+ this.lock = new ReentrantLock();
+ }
+
+ /**
+ * Stores the dictionary under the compound key formed from the privacy partition, its
+ * {@link CompressionDictionary#getSource() source} origin and SHA-256 hash. A pre-existing entry
+ * with the same key is replaced and promoted to the most-recently-added position; the store then
+ * evicts its eldest entries until it no longer exceeds {@code maxEntries}.
+ *
+ * @param partition the cookie storage partition; must not be {@code null}.
+ * @param dictionary the dictionary to store; must not be {@code null}.
+ * @throws NullPointerException if {@code partition} or {@code dictionary} is {@code null}.
+ * @since 5.7
+ */
+ @Override
+ public void add(
+ final CookieStore partition,
+ final CompressionDictionary dictionary) {
+ Args.notNull(partition, "Cookie partition");
+ Args.notNull(dictionary, "Dictionary");
+
+ lock.lock();
+ try {
+ final Key key = key(
+ partition,
+ dictionary.getSource(),
+ dictionary.getSha256());
+
+ dictionaries.remove(key);
+ dictionaries.put(key, dictionary);
+
+ while (dictionaries.size() > maxEntries) {
+ final Iterator iterator =
+ dictionaries.keySet().iterator();
+ if (iterator.hasNext()) {
+ iterator.next();
+ iterator.remove();
+ }
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Returns the dictionary previously stored for the given origin and SHA-256 hash. This resolves a
+ * single candidate by the exact hash a response advertises, for example the hash carried in the
+ * {@code Available-Dictionary} negotiation.
+ *
+ * @param partition the cookie storage partition; must not be {@code null}.
+ * @param origin the request origin the dictionary was stored against; must not be {@code null}.
+ * @param sha256 the SHA-256 hash of the dictionary content; must not be {@code null}.
+ * @return the matching dictionary, or {@code null} if none is stored under that key.
+ * @throws NullPointerException if {@code partition}, {@code origin} or {@code sha256} is {@code null}.
+ * @since 5.7
+ */
+ @Override
+ public CompressionDictionary getByHash(
+ final CookieStore partition,
+ final URI origin,
+ final byte[] sha256) {
+ Args.notNull(partition, "Cookie partition");
+ Args.notNull(origin, "Origin");
+ Args.notNull(sha256, "SHA-256");
+
+ lock.lock();
+ try {
+ return dictionaries.get(key(partition, origin, sha256));
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Returns all dictionaries whose source shares the scheme, host and effective port of the given
+ * origin, in insertion order. This is the set of candidates a client may advertise for an outbound
+ * request to that origin, before further filtering by match pattern or freshness.
+ *
+ * @param partition the cookie storage partition; must not be {@code null}.
+ * @param origin the request origin to match; must not be {@code null}.
+ * @return a newly allocated, modifiable list of matching dictionaries, empty if none match.
+ * @throws NullPointerException if {@code partition} or {@code origin} is {@code null}.
+ * @since 5.7
+ */
+ @Override
+ public List getByOrigin(
+ final CookieStore partition,
+ final URI origin) {
+ Args.notNull(partition, "Cookie partition");
+ Args.notNull(origin, "Origin");
+
+ lock.lock();
+ try {
+ final List result =
+ new ArrayList<>();
+
+ for (final Map.Entry entry
+ : dictionaries.entrySet()) {
+ if (entry.getKey().partition == partition
+ && sameOrigin(origin, entry.getValue().getSource())) {
+ result.add(entry.getValue());
+ }
+ }
+
+ return result;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Removes all stored dictionaries, returning the store to an empty state.
+ *
+ * @since 5.7
+ */
+ @Override
+ public void clear() {
+ lock.lock();
+ try {
+ dictionaries.clear();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public void clear(final CookieStore partition) {
+ Args.notNull(partition, "Cookie partition");
+ lock.lock();
+ try {
+ final Iterator iterator = dictionaries.keySet().iterator();
+ while (iterator.hasNext()) {
+ if (iterator.next().partition == partition) {
+ iterator.remove();
+ }
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private static Key key(
+ final CookieStore partition,
+ final URI origin,
+ final byte[] sha256) {
+ return new Key(partition, originKey(origin), Base64.getEncoder().encodeToString(sha256));
+ }
+
+ private static String originKey(final URI uri) {
+ return uri.getScheme().toLowerCase(Locale.ROOT)
+ + "://"
+ + uri.getHost().toLowerCase(Locale.ROOT)
+ + ':'
+ + effectivePort(uri);
+ }
+
+ private static boolean sameOrigin(
+ final URI first,
+ final URI second) {
+ return equalsIgnoreCase(first.getScheme(), second.getScheme())
+ && equalsIgnoreCase(first.getHost(), second.getHost())
+ && effectivePort(first) == effectivePort(second);
+ }
+
+ private static boolean equalsIgnoreCase(
+ final String first,
+ final String second) {
+ return first != null
+ && second != null
+ && first.equalsIgnoreCase(second);
+ }
+
+ private static int effectivePort(final URI uri) {
+ if (uri.getPort() >= 0) {
+ return uri.getPort();
+ }
+ if ("https".equalsIgnoreCase(uri.getScheme())) {
+ return 443;
+ }
+ return -1;
+ }
+
+ private static final class Key {
+ private final CookieStore partition;
+ private final String origin;
+ private final String hash;
+
+ Key(final CookieStore partition, final String origin, final String hash) {
+ this.partition = partition;
+ this.origin = origin;
+ this.hash = hash;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = System.identityHashCode(partition);
+ result = 31 * result + origin.hashCode();
+ result = 31 * result + hash.hashCode();
+ return result;
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof Key)) {
+ return false;
+ }
+ final Key that = (Key) obj;
+ return partition == that.partition
+ && origin.equals(that.origin)
+ && hash.equals(that.hash);
+ }
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CompressionDictionary.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CompressionDictionary.java
new file mode 100644
index 0000000000..87360e9010
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CompressionDictionary.java
@@ -0,0 +1,320 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.entity.compress;
+
+import java.net.URI;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * An immutable compression dictionary used by Compression Dictionary Transport.
+ *
+ * A dictionary carries the raw bytes that seed a Dictionary-Compressed Brotli ({@code dcb}) or
+ * Dictionary-Compressed Zstandard ({@code dcz}) decoder, together with the metadata needed to
+ * negotiate its use with an origin. The {@code match} pattern advertised in the origin's
+ * {@code Use-As-Dictionary} response selects the requests a dictionary applies to; the SHA-256
+ * hash is offered in the {@code Available-Dictionary} request header. The optional server supplied
+ * identifier is echoed independently in the {@code Dictionary-ID} request header.
+ *
+ * Instances are value objects: the dictionary content is defensively copied on construction and
+ * on every access, and the SHA-256 hash is computed once from that copy, so the observable state
+ * never changes after construction.
+ *
+ * @since 5.7
+ */
+@Contract(threading = ThreadingBehavior.IMMUTABLE)
+public final class CompressionDictionary {
+
+ private final byte[] content;
+ private final byte[] sha256;
+ private final URI source;
+ private final String match;
+ private final List matchDest;
+ private final String id;
+ private final String type;
+ private final Instant storedAt;
+ private final Instant validUntil;
+
+ /**
+ * Creates a dictionary with no {@code match-dest} restriction and the default {@code raw} format.
+ *
+ * @param content the raw dictionary bytes; copied defensively.
+ * @param source the URI the dictionary was fetched from.
+ * @param match the {@code match} URL pattern that selects the requests the dictionary applies to.
+ * @param id the opaque server identifier echoed in the {@code Dictionary-ID} request header,
+ * or {@code null} for none.
+ * @param storedAt the instant the dictionary was stored.
+ * @param validUntil the instant at which the dictionary stops being fresh; freshness is a half-open
+ * window ending strictly before this instant.
+ * @since 5.7
+ */
+ public CompressionDictionary(
+ final byte[] content,
+ final URI source,
+ final String match,
+ final String id,
+ final Instant storedAt,
+ final Instant validUntil) {
+ this(content, source, match, Collections.emptyList(), id, "raw", storedAt, validUntil);
+ }
+
+ /**
+ * Creates a dictionary with the full set of negotiation metadata.
+ *
+ * @param content the raw dictionary bytes; copied defensively.
+ * @param source the URI the dictionary was fetched from.
+ * @param match the {@code match} URL pattern that selects the requests the dictionary applies to.
+ * @param matchDest the optional {@code match-dest} destinations that further constrain which requests
+ * the dictionary applies to; copied defensively, {@code null} is treated as empty.
+ * @param id the opaque server identifier echoed in the {@code Dictionary-ID} request header,
+ * or {@code null} for none.
+ * @param type the format token; only {@code raw} is honoured, {@code null} defaults to {@code raw}.
+ * @param storedAt the instant the dictionary was stored.
+ * @param validUntil the instant at which the dictionary stops being fresh.
+ * @throws NullPointerException if {@code content}, {@code source}, {@code match}, {@code storedAt}
+ * or {@code validUntil} is {@code null}.
+ * @throws IllegalArgumentException if {@code match} is blank or a {@code matchDest}
+ * element is {@code null}.
+ * @since 5.7
+ */
+ public CompressionDictionary(
+ final byte[] content,
+ final URI source,
+ final String match,
+ final List matchDest,
+ final String id,
+ final String type,
+ final Instant storedAt,
+ final Instant validUntil) {
+ this.content = Args.notNull(content, "Content").clone();
+ this.sha256 = sha256(this.content);
+ this.source = validateSource(Args.notNull(source, "Source"));
+ this.match = validateString(Args.notBlank(match, "Match"), "Match", Integer.MAX_VALUE);
+ this.matchDest = copyMatchDest(matchDest);
+ this.id = validateString(id != null ? id : "", "ID", 1024);
+ this.type = validateToken(type != null ? type : "raw");
+ this.storedAt = Args.notNull(storedAt, "Stored at");
+ this.validUntil = Args.notNull(validUntil, "Valid until");
+ }
+
+ /**
+ * Returns a defensive copy of the raw dictionary bytes that seed the {@code dcb} or {@code dcz}
+ * decoder.
+ *
+ * @return a fresh copy of the dictionary content.
+ * @since 5.7
+ */
+ public byte[] getContent() {
+ return content.clone();
+ }
+
+ /**
+ * Returns a defensive copy of the SHA-256 digest of the dictionary content. The digest is offered
+ * to the origin in the {@code Available-Dictionary} request header.
+ *
+ * @return a fresh copy of the SHA-256 digest.
+ * @since 5.7
+ */
+ public byte[] getSha256() {
+ return sha256.clone();
+ }
+
+ /**
+ * Returns the URI the dictionary was fetched from.
+ *
+ * @return the source URI.
+ * @since 5.7
+ */
+ public URI getSource() {
+ return source;
+ }
+
+ /**
+ * Returns the {@code match} URL pattern advertised in the origin's {@code Use-As-Dictionary}
+ * response that selects the requests this dictionary applies to.
+ *
+ * @return the match pattern.
+ * @since 5.7
+ */
+ public String getMatch() {
+ return match;
+ }
+
+ /**
+ * Returns the optional {@code match-dest} destinations that further constrain which requests the
+ * dictionary applies to. The list is unmodifiable and empty when no destinations were advertised.
+ *
+ * @return the immutable list of match destinations.
+ * @since 5.7
+ */
+ public List getMatchDest() {
+ return matchDest;
+ }
+
+ /**
+ * Returns the opaque server identifier echoed back in the {@code Dictionary-ID} request header, or
+ * an empty string when the origin supplied none.
+ *
+ * @return the dictionary identifier, never {@code null}.
+ * @since 5.7
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Returns the format token declared for this dictionary. Only {@code raw} is honoured.
+ *
+ * @return the format type token.
+ * @since 5.7
+ */
+ public String getType() {
+ return type;
+ }
+
+ /**
+ * Returns the instant the dictionary was stored.
+ *
+ * @return the storage instant.
+ * @since 5.7
+ */
+ public Instant getStoredAt() {
+ return storedAt;
+ }
+
+ /**
+ * Returns the instant at which the dictionary stops being fresh. Freshness ends strictly before
+ * this instant.
+ *
+ * @return the freshness boundary.
+ * @since 5.7
+ */
+ public Instant getValidUntil() {
+ return validUntil;
+ }
+
+ /**
+ * Tests whether the dictionary is still fresh at the given instant. The check is half-open: the
+ * dictionary is fresh strictly before {@link #getValidUntil()} and stale from that instant onward.
+ *
+ * @param now the instant to test against.
+ * @return {@code true} if {@code now} precedes the freshness boundary.
+ * @since 5.7
+ */
+ public boolean isFresh(final Instant now) {
+ return now.isBefore(validUntil);
+ }
+
+ /**
+ * Tests whether the supplied hash equals the stored SHA-256 digest. The comparison uses
+ * {@link MessageDigest#isEqual(byte[], byte[])} to avoid leaking timing information.
+ *
+ * @param hash the hash to compare, may be {@code null}.
+ * @return {@code true} if {@code hash} is non-{@code null} and matches the stored digest.
+ * @since 5.7
+ */
+ public boolean matchesHash(final byte[] hash) {
+ return hash != null && MessageDigest.isEqual(sha256, hash);
+ }
+
+ private static byte[] sha256(final byte[] content) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(content);
+ } catch (final NoSuchAlgorithmException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private static URI validateSource(final URI source) {
+ if (!source.isAbsolute()
+ || source.getHost() == null
+ || !"https".equalsIgnoreCase(source.getScheme())) {
+ throw new IllegalArgumentException("Source must be an absolute HTTPS URI");
+ }
+ return source;
+ }
+
+ private static List copyMatchDest(final List values) {
+ if (values == null || values.isEmpty()) {
+ return Collections.emptyList();
+ }
+ final List copy = new ArrayList<>(values.size());
+ for (final String value : values) {
+ if (value == null) {
+ throw new IllegalArgumentException("Match destination must not be null");
+ }
+ copy.add(validateString(value, "Match destination", Integer.MAX_VALUE));
+ }
+ return Collections.unmodifiableList(copy);
+ }
+
+ private static String validateString(
+ final String value,
+ final String name,
+ final int maxLength) {
+ if (value.length() > maxLength) {
+ throw new IllegalArgumentException(name + " exceeds " + maxLength + " characters");
+ }
+ for (int i = 0; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (ch < 0x20 || ch > 0x7e) {
+ throw new IllegalArgumentException(name + " is not an RFC 9651 String");
+ }
+ }
+ return value;
+ }
+
+ private static String validateToken(final String value) {
+ if (value.isEmpty() || !isTokenStart(value.charAt(0))) {
+ throw new IllegalArgumentException("Type is not an RFC 9651 Token");
+ }
+ for (int i = 1; i < value.length(); i++) {
+ if (!isTokenChar(value.charAt(i))) {
+ throw new IllegalArgumentException("Type is not an RFC 9651 Token");
+ }
+ }
+ return value;
+ }
+
+ private static boolean isTokenStart(final char ch) {
+ return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch == '*';
+ }
+
+ private static boolean isTokenChar(final char ch) {
+ return isTokenStart(ch)
+ || ch >= '0' && ch <= '9'
+ || "!#$%&'*+-.^_`|~:/".indexOf(ch) >= 0;
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CompressionDictionaryStore.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CompressionDictionaryStore.java
new file mode 100644
index 0000000000..1057b2240b
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CompressionDictionaryStore.java
@@ -0,0 +1,106 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.entity.compress;
+
+import java.net.URI;
+import java.util.List;
+
+import org.apache.hc.client5.http.cookie.CookieStore;
+
+/**
+ * Repository of {@link CompressionDictionary} instances available to the client for
+ * decoding dictionary-compressed Brotli ({@code dcb}) and dictionary-compressed
+ * Zstandard ({@code dcz}) responses under Compression Dictionary Transport.
+ *
+ * Entries are keyed by privacy partition, request origin and the SHA-256 hash of their content.
+ * The privacy partition is represented by the {@link CookieStore} used for the exchange, ensuring
+ * dictionary state is never shared more broadly than cookie state.
+ * The hash-keyed lookup resolves the exact dictionary a response was encoded against,
+ * named by the hash carried in a {@code dcb} or {@code dcz} frame or advertised to the
+ * origin in the {@code Available-Dictionary} header. The origin-scoped lookup yields the
+ * candidates whose {@code Use-As-Dictionary} match pattern applies to an outbound request,
+ * from which the client selects what to advertise.
+ *
+ * Implementations are expected to be thread-safe, since a store is shared across
+ * concurrent exchanges. The store keeps entries as supplied; enforcing freshness against
+ * {@link CompressionDictionary#getValidUntil()} is the caller's responsibility.
+ *
+ * @since 5.7
+ */
+public interface CompressionDictionaryStore {
+
+ /**
+ * Stores a dictionary, replacing any entry already held under the same origin and
+ * SHA-256 hash.
+ *
+ * @param partition the cookie storage partition; must not be {@code null}.
+ * @param dictionary the dictionary to store; must not be {@code null}.
+ * @since 5.7
+ */
+ void add(CookieStore partition, CompressionDictionary dictionary);
+
+ /**
+ * Resolves the dictionary a response was encoded against, identified by the SHA-256
+ * hash carried in a {@code dcb} or {@code dcz} frame or advertised in the
+ * {@code Available-Dictionary} header, scoped to the response origin.
+ *
+ * @param partition the cookie storage partition; must not be {@code null}.
+ * @param origin the origin the dictionary is associated with; must not be {@code null}.
+ * @param sha256 the SHA-256 hash of the dictionary content; must not be {@code null}.
+ * @return the matching dictionary, or {@code null} if none is held for the given
+ * origin and hash.
+ * @since 5.7
+ */
+ CompressionDictionary getByHash(CookieStore partition, URI origin, byte[] sha256);
+
+ /**
+ * Returns the dictionaries associated with the given request origin, that is the
+ * candidates eligible to be advertised through {@code Available-Dictionary} for a
+ * request to that origin.
+ *
+ * @param partition the cookie storage partition; must not be {@code null}.
+ * @param origin the request origin to look up; must not be {@code null}.
+ * @return the matching dictionaries, never {@code null}; an empty list if none apply.
+ * @since 5.7
+ */
+ List getByOrigin(CookieStore partition, URI origin);
+
+ /**
+ * Removes all dictionaries associated with a cookie storage partition.
+ *
+ * @param partition the cookie storage partition; must not be {@code null}.
+ * @since 5.7
+ */
+ void clear(CookieStore partition);
+
+ /**
+ * Removes all stored dictionaries.
+ *
+ * @since 5.7
+ */
+ void clear();
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentCoding.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentCoding.java
index 3a8f169777..f0fc3bbab2 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentCoding.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentCoding.java
@@ -66,6 +66,14 @@ public enum ContentCoding {
* Zstandard compression format.
*/
ZSTD("zstd"),
+ /**
+ * Dictionary-Compressed Brotli format.
+ */
+ DCB("dcb"),
+ /**
+ * Dictionary-Compressed Zstandard format.
+ */
+ DCZ("dcz"),
/**
* XZ compression format.
*/
@@ -131,4 +139,4 @@ public String token() {
public static ContentCoding fromToken(final String token) {
return token != null ? TOKEN_LOOKUP.get(token.toLowerCase(Locale.ROOT)) : null;
}
-}
+}
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryCookieStore.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryCookieStore.java
new file mode 100644
index 0000000000..1344337a8f
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryCookieStore.java
@@ -0,0 +1,94 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.time.Instant;
+import java.util.Date;
+import java.util.List;
+
+import org.apache.hc.client5.http.cookie.Cookie;
+import org.apache.hc.client5.http.cookie.CookieStore;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionaryStore;
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Binds the cookie and compression dictionary privacy partitions without
+ * adding compression-specific responsibilities to {@link CookieStore}.
+ *
+ * @since 5.7
+ */
+@Contract(threading = ThreadingBehavior.SAFE_CONDITIONAL)
+final class CompressionDictionaryCookieStore implements CookieStore {
+
+ private final CookieStore delegate;
+ private final CompressionDictionaryStore compressionDictionaryStore;
+
+ CompressionDictionaryCookieStore(
+ final CookieStore delegate,
+ final CompressionDictionaryStore compressionDictionaryStore) {
+ this.delegate = Args.notNull(delegate, "Cookie store");
+ this.compressionDictionaryStore = Args.notNull(
+ compressionDictionaryStore, "Compression dictionary store");
+ }
+
+ boolean isBoundTo(final CompressionDictionaryStore store) {
+ return compressionDictionaryStore == store;
+ }
+
+ @Override
+ public void addCookie(final Cookie cookie) {
+ delegate.addCookie(cookie);
+ }
+
+ @Override
+ public List getCookies() {
+ return delegate.getCookies();
+ }
+
+ @Override
+ @SuppressWarnings("deprecation")
+ public boolean clearExpired(final Date date) {
+ return delegate.clearExpired(date);
+ }
+
+ @Override
+ public boolean clearExpired(final Instant date) {
+ return delegate.clearExpired(date);
+ }
+
+ @Override
+ public void clear() {
+ try {
+ delegate.clear();
+ } finally {
+ compressionDictionaryStore.clear(this);
+ }
+ }
+
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryFreshness.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryFreshness.java
new file mode 100644
index 0000000000..636305a478
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryFreshness.java
@@ -0,0 +1,259 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.util.Locale;
+
+import org.apache.hc.client5.http.utils.DateUtils;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpResponse;
+
+/**
+ * Derives, from the caching metadata of a dictionary response, the instant up to which the
+ * dictionary may still be treated as a match on a later request. The calculation follows the
+ * shared-cache freshness model: a freshness lifetime taken from {@code Cache-Control: max-age}
+ * or, in its absence, from {@code Expires} measured against {@code Date}, reduced by the response
+ * age derived from {@code Date}, {@code Age} and the request/response round trip. A
+ * {@code no-store} directive makes the response non-storable and a {@code no-cache} directive
+ * makes it immediately non-fresh; either directive, along with any missing or malformed input the
+ * calculation relies on, yields {@code null}.
+ *
+ * Used by the Compression Dictionary Transport machinery to decide whether a previously fetched
+ * dictionary is still eligible to be offered through {@code Available-Dictionary}.
+ */
+final class CompressionDictionaryFreshness {
+
+ private static final DateTimeFormatter ASCTIME_FORMATTER = new DateTimeFormatterBuilder()
+ .parseLenient()
+ .parseCaseInsensitive()
+ .appendPattern(DateUtils.PATTERN_ASCTIME)
+ .toFormatter(Locale.ENGLISH);
+
+ /** Not to be instantiated. */
+ private CompressionDictionaryFreshness() {
+ }
+
+ /**
+ * Computes the instant up to which the dictionary carried by the given response stays fresh and
+ * may therefore be advertised as a match. The freshness lifetime is taken from
+ * {@code Cache-Control: max-age} when present, otherwise from {@code Expires} measured against
+ * {@code Date} (or against {@code responseTime} when {@code Date} is absent). A corrected initial
+ * age is derived from {@code Date}, {@code Age} and the transmission delay and subtracted from the
+ * freshness lifetime; the remaining seconds are added to {@code responseTime} to yield the result.
+ *
+ * @param response the dictionary response whose caching headers are inspected.
+ * @param requestTime the instant the request was sent; combined with {@code responseTime} it
+ * accounts for the transmission delay when correcting the reported age.
+ * @param responseTime the instant the response was received; the freshness deadline is expressed
+ * relative to it.
+ * @return the instant until which the dictionary remains fresh, or {@code null} when the response
+ * is non-storable ({@code no-store}), immediately non-fresh ({@code no-cache} or a
+ * malformed {@code Cache-Control}), has no usable freshness lifetime, carries a malformed
+ * {@code Age}, is already stale, or when the deadline would overflow the representable
+ * range.
+ */
+ static Instant determineValidUntil(
+ final HttpResponse response,
+ final Instant requestTime,
+ final Instant responseTime) {
+
+ final CacheControl cacheControl = parseCacheControl(response.getHeaders(HttpHeaders.CACHE_CONTROL));
+ if (cacheControl.noStore || cacheControl.noCache) {
+ return null;
+ }
+
+ final Instant date = parseHttpDate(response.getFirstHeader(HttpHeaders.DATE));
+ final long freshnessLifetime;
+ if (cacheControl.maxAge >= 0) {
+ freshnessLifetime = cacheControl.maxAge;
+ } else {
+ final Instant expires = parseHttpDate(response.getFirstHeader(HttpHeaders.EXPIRES));
+ if (expires == null) {
+ return null;
+ }
+ final Instant reference = date != null ? date : responseTime;
+ freshnessLifetime = Math.max(0, Duration.between(reference, expires).getSeconds());
+ }
+
+ final long ageValue = parseAge(response.getFirstHeader(HttpHeaders.AGE));
+ if (ageValue < 0) {
+ return null;
+ }
+
+ final long apparentAge = date != null
+ ? Math.max(0, Duration.between(date, responseTime).getSeconds())
+ : 0;
+ final long responseDelay = Math.max(0, Duration.between(requestTime, responseTime).getSeconds());
+ final long correctedAgeValue = saturatedAdd(ageValue, responseDelay);
+ final long correctedInitialAge = Math.max(apparentAge, correctedAgeValue);
+ final long remaining = freshnessLifetime - correctedInitialAge;
+ if (remaining <= 0) {
+ return null;
+ }
+ try {
+ return responseTime.plusSeconds(remaining);
+ } catch (final RuntimeException ex) {
+ return null;
+ }
+ }
+
+ /**
+ * Parses the {@code Age} header. A missing header is treated as an age of zero, a well-formed
+ * non-negative value is returned as seconds, and a negative or unparseable value returns
+ * {@code -1} to signal that the response must be treated as non-fresh.
+ *
+ * @param header the {@code Age} header, or {@code null} if absent.
+ * @return the age in seconds, {@code 0} when the header is absent, or {@code -1} when the value is
+ * invalid.
+ */
+ private static long parseAge(final Header header) {
+ if (header == null) {
+ return 0;
+ }
+ try {
+ final long age = Long.parseLong(header.getValue().trim());
+ return age >= 0 ? age : -1;
+ } catch (final NumberFormatException ex) {
+ return -1;
+ }
+ }
+
+ /**
+ * Parses a date-valued header in any HTTP-date format accepted by HTTP semantics.
+ *
+ * @param header the date-valued header ({@code Date} or {@code Expires}), or {@code null} if absent.
+ * @return the parsed instant, or {@code null} when the header is absent or its value cannot be
+ * parsed.
+ */
+ private static Instant parseHttpDate(final Header header) {
+ if (header == null) {
+ return null;
+ }
+ final Instant standardDate = DateUtils.parseStandardDate(header.getValue());
+ if (standardDate != null) {
+ return standardDate;
+ }
+ try {
+ return LocalDateTime.parse(header.getValue(), ASCTIME_FORMATTER)
+ .toInstant(ZoneOffset.UTC);
+ } catch (final RuntimeException ex) {
+ return null;
+ }
+ }
+
+ /**
+ * Extracts the {@code no-store}, {@code no-cache} and {@code max-age} directives from the given
+ * {@code Cache-Control} headers, folding all header lines and comma-separated elements together.
+ * A quoted {@code max-age} value is unquoted before parsing. A negative, unparseable or
+ * self-contradicting {@code max-age} (the directive repeated with conflicting values) degrades the
+ * result to an {@linkplain CacheControl#invalid() invalid} control block flagged {@code no-cache},
+ * so a broken directive fails closed as non-fresh rather than being silently ignored.
+ *
+ * @param headers the {@code Cache-Control} headers, or {@code null} if none are present.
+ * @return the parsed directives; never {@code null}.
+ */
+ private static CacheControl parseCacheControl(final Header[] headers) {
+ final CacheControl result = new CacheControl();
+ if (headers == null) {
+ return result;
+ }
+ for (final Header header : headers) {
+ final String[] elements = header.getValue().split(",");
+ for (final String element : elements) {
+ final String directive = element.trim();
+ if ("no-store".equalsIgnoreCase(directive)) {
+ result.noStore = true;
+ } else if ("no-cache".equalsIgnoreCase(directive)) {
+ result.noCache = true;
+ } else if (directive.regionMatches(true, 0, "max-age=", 0, 8)) {
+ String value = directive.substring(8).trim();
+ if (value.length() > 1 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') {
+ value = value.substring(1, value.length() - 1);
+ }
+ try {
+ final long maxAge = Long.parseLong(value);
+ if (maxAge < 0) {
+ return CacheControl.invalid();
+ }
+ if (result.maxAge >= 0 && result.maxAge != maxAge) {
+ return CacheControl.invalid();
+ }
+ result.maxAge = maxAge;
+ } catch (final NumberFormatException ex) {
+ return CacheControl.invalid();
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Adds two non-negative second counts, clamping to {@link Long#MAX_VALUE} instead of overflowing,
+ * so that an extreme reported age combined with the transmission delay cannot wrap around into a
+ * misleadingly small corrected age.
+ *
+ * @param first the first summand.
+ * @param second the second summand.
+ * @return the sum, or {@link Long#MAX_VALUE} if the true sum would overflow.
+ */
+ private static long saturatedAdd(final long first, final long second) {
+ if (Long.MAX_VALUE - first < second) {
+ return Long.MAX_VALUE;
+ }
+ return first + second;
+ }
+
+ /**
+ * Mutable holder for the subset of {@code Cache-Control} directives that bear on dictionary
+ * freshness. A {@code maxAge} of {@code -1} means the directive was absent.
+ */
+ private static final class CacheControl {
+ private boolean noStore;
+ private boolean noCache;
+ private long maxAge = -1;
+
+ /**
+ * Returns a control block that fails closed by flagging {@code no-cache}, used when a
+ * {@code max-age} directive is malformed or contradictory.
+ *
+ * @return a {@code no-cache} control block.
+ */
+ static CacheControl invalid() {
+ final CacheControl value = new CacheControl();
+ value.noCache = true;
+ return value;
+ }
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryHeaderSupport.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryHeaderSupport.java
new file mode 100644
index 0000000000..c0b7f5c615
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryHeaderSupport.java
@@ -0,0 +1,109 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.util.Base64;
+
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Names the Compression Dictionary Transport headers and encodes the request headers used to
+ * negotiate a dictionary. {@code Available-Dictionary} carries the SHA-256 hash of the stored
+ * dictionary as an HTTP Structured Field Byte Sequence, and {@code Dictionary-ID} carries the
+ * opaque identifier the origin assigned through {@code Use-As-Dictionary} as a Structured Field
+ * String. Both header values are produced in the wire form required by the negotiation rather than
+ * as raw bytes or text.
+ *
+ * Stateless and not instantiable.
+ */
+final class CompressionDictionaryHeaderSupport {
+
+ /**
+ * Response header through which an origin designates a resource as a dictionary and declares
+ * how it may be matched against future requests.
+ */
+ static final String USE_AS_DICTIONARY = "Use-As-Dictionary";
+
+ /**
+ * Request header naming the dictionary the client holds, keyed by the SHA-256 hash of its
+ * content encoded as a Structured Field Byte Sequence.
+ */
+ static final String AVAILABLE_DICTIONARY = "Available-Dictionary";
+
+ /**
+ * Request header echoing the opaque identifier the origin bound to the dictionary via
+ * {@code Use-As-Dictionary}, encoded as a Structured Field String.
+ */
+ static final String DICTIONARY_ID = "Dictionary-ID";
+
+ private CompressionDictionaryHeaderSupport() {
+ }
+
+ /**
+ * Encodes a dictionary hash as the {@code Available-Dictionary} value. The hash is emitted as a
+ * Structured Field Byte Sequence, that is base64 wrapped in a leading and trailing colon.
+ *
+ * @param hash the SHA-256 hash of the dictionary content; must not be {@code null}.
+ * @return the {@code Available-Dictionary} field value.
+ */
+ static String formatAvailableDictionary(final byte[] hash) {
+ return ":" + Base64.getEncoder().encodeToString(Args.notNull(hash, "Dictionary hash")) + ":";
+ }
+
+ /**
+ * Encodes a dictionary identifier as the {@code Dictionary-ID} value. The identifier is emitted
+ * as a Structured Field String, double quoted with a backslash escaping any embedded quote or
+ * backslash. The string form only admits printable ASCII, so any character outside the range
+ * {@code 0x20} to {@code 0x7e} is rejected, as is a value longer than the 1024-character limit
+ * imposed on the identifier.
+ *
+ * @param value the opaque dictionary identifier; must not be {@code null}.
+ * @return the {@code Dictionary-ID} field value.
+ * @throws IllegalArgumentException if the value exceeds 1024 characters or contains a character
+ * that a Structured Field String cannot represent.
+ */
+ static String formatDictionaryId(final String value) {
+ Args.notNull(value, "Dictionary ID");
+ if (value.length() > 1024) {
+ throw new IllegalArgumentException("Dictionary ID length exceeds 1024 characters");
+ }
+ final StringBuilder buffer = new StringBuilder(value.length() + 2);
+ buffer.append('"');
+ for (int i = 0; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (ch < 0x20 || ch > 0x7e) {
+ throw new IllegalArgumentException("Dictionary ID contains a character not permitted in a Structured Field String");
+ }
+ if (ch == '"' || ch == '\\') {
+ buffer.append('\\');
+ }
+ buffer.append(ch);
+ }
+ buffer.append('"');
+ return buffer.toString();
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryMatcher.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryMatcher.java
new file mode 100644
index 0000000000..f3be1537e0
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryMatcher.java
@@ -0,0 +1,69 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.net.URI;
+import java.util.Collection;
+
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+
+/**
+ * Selects the compression dictionary to advertise for an outgoing request, following the
+ * Compression Dictionary Transport matching rules. Implementations decide which stored {@link CompressionDictionary},
+ * if any, is eligible for a given request URI; the winner is later announced to the origin through
+ * the {@code Available-Dictionary} header so it may return a {@code dcb} or {@code dcz} response.
+ *
+ * Eligibility is governed by the {@code Use-As-Dictionary} metadata carried on each dictionary:
+ * a candidate applies only when it is still fresh, shares the request's origin, and its stored
+ * URL pattern matches the request path. When several candidates qualify the implementation is
+ * expected to return the single most specific one.
+ *
+ * Implementations are expected to be stateless and safe for concurrent use.
+ */
+
+interface CompressionDictionaryMatcher {
+
+ /**
+ * Selects the single dictionary to advertise for the given request, or {@code null} when
+ * none is eligible. Only a candidate that is fresh, shares the request origin and whose
+ * stored URL pattern matches the request qualifies; when several qualify the most specific
+ * one is returned.
+ *
+ * @param requestUri the absolute request target. Dictionary transport is confined to secure
+ * origins, so a target whose scheme is not {@code https} matches nothing.
+ * @param requestDestination the request destination the response is bound for, tested against
+ * each candidate's {@code match-dest}, or {@code null} when the caller does not support
+ * request destinations.
+ * @param dictionaries the stored dictionaries to consider.
+ * @return the winning dictionary, whose identifier the caller offers to the origin through
+ * {@code Available-Dictionary}, or {@code null} when no candidate is eligible.
+ */
+ CompressionDictionary match(
+ URI requestUri,
+ String requestDestination,
+ Collection dictionaries);
+}
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryUrlPatternMatcher.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryUrlPatternMatcher.java
new file mode 100644
index 0000000000..766bd5cc45
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/CompressionDictionaryUrlPatternMatcher.java
@@ -0,0 +1,878 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.net.IDN;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/**
+ * Validates and evaluates the URL pattern carried by a
+ * {@code Use-As-Dictionary} response header.
+ */
+interface CompressionDictionaryUrlPatternMatcher {
+
+ /**
+ * Validates a pattern using the dictionary request URL as its base URL.
+ *
+ * @param pattern the URL pattern.
+ * @param dictionaryUri the dictionary request URL.
+ * @return whether the pattern is valid and confined to the dictionary origin.
+ */
+ boolean isValid(String pattern, URI dictionaryUri);
+
+ /**
+ * Evaluates a pattern using the outbound request URL as its base URL.
+ *
+ * @param pattern the URL pattern.
+ * @param dictionaryUri the dictionary request URL.
+ * @param requestUri the outbound request URL.
+ * @return whether the outbound URL matches.
+ */
+ boolean matches(String pattern, URI dictionaryUri, URI requestUri);
+}
+
+/**
+ * URLPattern matcher for RFC 9842. Regular-expression groups are rejected,
+ * while wildcard, named and brace groups remain available.
+ */
+final class DefaultCompressionDictionaryUrlPatternMatcher
+ implements CompressionDictionaryUrlPatternMatcher {
+
+ @Override
+ public boolean isValid(final String pattern, final URI dictionaryUri) {
+ if (pattern == null || pattern.isEmpty() || !isAbsoluteHttpUri(dictionaryUri)) {
+ return false;
+ }
+ try {
+ UrlPattern.compile(pattern, dictionaryUri);
+ return true;
+ } catch (final IllegalArgumentException ex) {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean matches(
+ final String pattern,
+ final URI dictionaryUri,
+ final URI requestUri) {
+ if (!isValid(pattern, dictionaryUri)
+ || !isAbsoluteHttpUri(requestUri)
+ || !sameOrigin(dictionaryUri, requestUri)) {
+ return false;
+ }
+ try {
+ // RFC 9842 section 2.2.2 deliberately uses the outbound URL as baseURL.
+ return UrlPattern.compile(pattern, requestUri).matches(requestUri);
+ } catch (final IllegalArgumentException ex) {
+ return false;
+ }
+ }
+
+ private static boolean isAbsoluteHttpUri(final URI uri) {
+ return uri != null
+ && uri.isAbsolute()
+ && uri.getHost() != null
+ && ("http".equalsIgnoreCase(uri.getScheme())
+ || "https".equalsIgnoreCase(uri.getScheme()));
+ }
+
+ private static boolean sameOrigin(final URI first, final URI second) {
+ return first.getScheme().equalsIgnoreCase(second.getScheme())
+ && normalizeHost(first.getHost()).equals(normalizeHost(second.getHost()))
+ && effectivePort(first) == effectivePort(second);
+ }
+
+ private static int effectivePort(final URI uri) {
+ if (uri.getPort() >= 0) {
+ return uri.getPort();
+ }
+ return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
+ }
+
+ private static String normalizeHost(final String host) {
+ final String value = host.length() > 1 && host.charAt(0) == '['
+ && host.charAt(host.length() - 1) == ']'
+ ? host.substring(1, host.length() - 1)
+ : host;
+ if (value.indexOf(':') >= 0) {
+ return value.toLowerCase(Locale.ROOT);
+ }
+ return IDN.toASCII(value).toLowerCase(Locale.ROOT);
+ }
+
+ /**
+ * Compiled URLPattern string. Constructor-string parsing and component
+ * matching are intentionally kept out of the dictionary selection policy.
+ */
+ private static final class UrlPattern {
+
+ private final Pattern protocol;
+ private final Pattern username;
+ private final Pattern password;
+ private final Pattern hostname;
+ private final Pattern port;
+ private final Pattern pathname;
+ private final Pattern search;
+ private final Pattern hash;
+
+ private UrlPattern(final ConstructorParts parts) {
+ this.protocol = compileComponent(parts.protocol, Component.PROTOCOL);
+ this.username = compileComponent(parts.username, Component.DEFAULT);
+ this.password = compileComponent(parts.password, Component.DEFAULT);
+ this.hostname = compileComponent(parts.hostname, Component.HOSTNAME);
+ this.port = compileComponent(parts.port, Component.DEFAULT);
+ this.pathname = compileComponent(parts.pathname, Component.PATHNAME);
+ this.search = compileComponent(parts.search, Component.DEFAULT);
+ this.hash = compileComponent(parts.hash, Component.DEFAULT);
+ }
+
+ static UrlPattern compile(final String input, final URI baseUri) {
+ return new UrlPattern(ConstructorParts.parse(input, baseUri));
+ }
+
+ boolean matches(final URI uri) {
+ final UserInfo userInfo = UserInfo.from(uri);
+ return matches(protocol, uri.getScheme().toLowerCase(Locale.ROOT))
+ && matches(username, userInfo.username)
+ && matches(password, userInfo.password)
+ && matches(hostname, normalizeHost(uri.getHost()))
+ && matches(port, normalizedPort(uri))
+ && matches(pathname, rawPath(uri))
+ && matches(search, valueOrEmpty(uri.getRawQuery()))
+ && matches(hash, valueOrEmpty(uri.getRawFragment()));
+ }
+
+ private static boolean matches(final Pattern pattern, final String value) {
+ return pattern == null || pattern.matcher(value).matches();
+ }
+ }
+
+ /** URLPattern constructor-string components; {@code null} means wildcard. */
+ private static final class ConstructorParts {
+
+ private String protocol;
+ private String username;
+ private String password;
+ private String hostname;
+ private String port;
+ private String pathname;
+ private String search;
+ private String hash;
+
+ static ConstructorParts parse(final String input, final URI baseUri) {
+ if (input == null || input.isEmpty() || baseUri == null || !baseUri.isAbsolute()) {
+ throw new IllegalArgumentException("Invalid URL pattern");
+ }
+ rejectRegexpGroups(input);
+
+ final ConstructorParts result = new ConstructorParts();
+ final int authorityMarker = findAuthorityMarker(input);
+ if (authorityMarker >= 0) {
+ result.parseAbsolute(input, authorityMarker);
+ } else if (input.startsWith("//")) {
+ result.protocol = baseUri.getScheme().toLowerCase(Locale.ROOT);
+ result.parseAuthorityAndTail(input, 2);
+ } else {
+ result.protocol = baseUri.getScheme().toLowerCase(Locale.ROOT);
+ result.hostname = normalizeHost(baseUri.getHost());
+ result.port = normalizedPort(baseUri);
+ result.parseRelativeTail(input, baseUri);
+ }
+ return result;
+ }
+
+ private void parseAbsolute(final String input, final int authorityMarker) {
+ final String scheme = input.substring(0, authorityMarker);
+ if (scheme.isEmpty()
+ || !containsComponentPatternSyntax(scheme) && !isScheme(scheme)) {
+ throw new IllegalArgumentException("Invalid URL pattern protocol");
+ }
+ protocol = scheme.toLowerCase(Locale.ROOT);
+ parseAuthorityAndTail(input, authorityMarker + 3);
+ }
+
+ private void parseAuthorityAndTail(
+ final String input,
+ final int authorityStart) {
+ final int bracedSlash = findBracedSlash(input, authorityStart);
+ if (bracedSlash >= 0) {
+ final String prefix = input.substring(authorityStart, bracedSlash);
+ final int openBrace = prefix.lastIndexOf('{');
+ parseAuthority(prefix.substring(0, openBrace)
+ + prefix.substring(openBrace + 1));
+ pathname = null;
+ search = null;
+ hash = null;
+ return;
+ }
+ final int tailStart = findTailStart(input, authorityStart);
+ final String authority = input.substring(
+ authorityStart, tailStart >= 0 ? tailStart : input.length());
+ parseAuthority(authority);
+
+ if (tailStart < 0) {
+ pathname = null;
+ search = null;
+ hash = null;
+ } else {
+ final String tail = input.substring(tailStart);
+ if (tail.charAt(0) == '?') {
+ pathname = "/";
+ parseTail(tail, pathname);
+ } else if (tail.charAt(0) == '#') {
+ pathname = "/";
+ search = "";
+ hash = tail.substring(1);
+ } else {
+ parseTail(tail, null);
+ }
+ }
+ }
+
+ private static int findBracedSlash(
+ final String value,
+ final int start) {
+ int braces = 0;
+ boolean escaped = false;
+ for (int i = start; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (ch == '\\') {
+ escaped = true;
+ } else if (ch == '{') {
+ braces++;
+ } else if (ch == '}') {
+ braces--;
+ } else if (ch == '/' && braces > 0) {
+ return i;
+ } else if (braces == 0
+ && (ch == '#' || ch == '?' && !isGroupModifier(value, i))) {
+ return -1;
+ }
+ }
+ return -1;
+ }
+
+ private void parseAuthority(final String authority) {
+ if (authority.isEmpty()) {
+ throw new IllegalArgumentException("URL pattern has no authority");
+ }
+
+ String hostPort = authority;
+ final int at = authority.lastIndexOf('@');
+ if (at >= 0) {
+ final String userInfo = authority.substring(0, at);
+ hostPort = authority.substring(at + 1);
+ final int colon = userInfoDelimiter(userInfo);
+ if (colon >= 0) {
+ final String parsedUsername = userInfo.substring(0, colon);
+ username = parsedUsername.endsWith("\\")
+ ? parsedUsername.substring(0, parsedUsername.length() - 1)
+ : parsedUsername;
+ password = userInfo.substring(colon + 1);
+ } else {
+ username = userInfo;
+ password = "";
+ }
+ }
+
+ final HostPort parsed = HostPort.parse(hostPort, protocol);
+ hostname = parsed.hostname;
+ port = parsed.port;
+ }
+
+ private static int userInfoDelimiter(final String value) {
+ boolean escaped = false;
+ for (int i = 0; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (ch == '\\') {
+ if (i + 1 < value.length() && value.charAt(i + 1) == ':') {
+ return i + 1;
+ }
+ escaped = true;
+ } else if (ch == ':') {
+ if (i == 0 && i + 1 < value.length()
+ && isNameStart(value.charAt(i + 1))) {
+ i++;
+ while (i + 1 < value.length()
+ && isNameChar(value.charAt(i + 1))) {
+ i++;
+ }
+ } else {
+ return i;
+ }
+ }
+ }
+ return -1;
+ }
+
+ private void parseRelativeTail(final String input, final URI baseUri) {
+ if (input.charAt(0) == '?') {
+ pathname = rawPath(baseUri);
+ parseTail(input, pathname);
+ } else if (input.charAt(0) == '#') {
+ pathname = rawPath(baseUri);
+ search = valueOrEmpty(baseUri.getRawQuery());
+ hash = input.substring(1);
+ } else {
+ parseTail(input, resolvePath(input, baseUri));
+ }
+ }
+
+ private void parseTail(final String tail, final String resolvedPath) {
+ final int hashIndex = findDelimiter(tail, '#');
+ final String beforeHash = hashIndex >= 0 ? tail.substring(0, hashIndex) : tail;
+ final int queryIndex = findQueryDelimiter(beforeHash);
+ final boolean escapedQuery = queryIndex > 0
+ && beforeHash.charAt(queryIndex - 1) == '\\';
+
+ final String path = queryIndex >= 0
+ ? beforeHash.substring(0, escapedQuery ? queryIndex - 1 : queryIndex)
+ : beforeHash;
+ if (resolvedPath != null) {
+ pathname = resolvedPath;
+ } else if (!path.isEmpty()) {
+ pathname = normalizePath(path);
+ }
+ if (queryIndex >= 0) {
+ search = beforeHash.substring(queryIndex + 1);
+ }
+ if (hashIndex >= 0) {
+ hash = tail.substring(hashIndex + 1);
+ }
+ }
+
+ private static String resolvePath(final String input, final URI baseUri) {
+ final int hashIndex = findDelimiter(input, '#');
+ final String beforeHash = hashIndex >= 0 ? input.substring(0, hashIndex) : input;
+ final int queryIndex = findQueryDelimiter(beforeHash);
+ final String path = queryIndex >= 0 ? beforeHash.substring(0, queryIndex) : beforeHash;
+ if (path.startsWith("/")) {
+ return path;
+ }
+ final String basePath = rawPath(baseUri);
+ final int slash = basePath.lastIndexOf('/');
+ return normalizePath(
+ (slash >= 0 ? basePath.substring(0, slash + 1) : "/") + path);
+ }
+ }
+
+ private static final class HostPort {
+ final String hostname;
+ final String port;
+
+ HostPort(final String hostname, final String port) {
+ this.hostname = hostname;
+ this.port = port;
+ }
+
+ static HostPort parse(final String value, final String protocol) {
+ if (value.isEmpty()) {
+ throw new IllegalArgumentException("URL pattern has no hostname");
+ }
+ final String host;
+ String port = "";
+ if (value.charAt(0) == '[') {
+ final int end = value.indexOf(']');
+ if (end < 0) {
+ throw new IllegalArgumentException("Invalid IPv6 hostname");
+ }
+ host = value.substring(1, end);
+ if (end + 1 < value.length()) {
+ if (value.charAt(end + 1) != ':') {
+ throw new IllegalArgumentException("Invalid authority");
+ }
+ port = value.substring(end + 2);
+ }
+ } else {
+ final int colon = portDelimiter(value);
+ if (colon >= 0) {
+ host = value.substring(0, colon);
+ port = value.substring(colon + 1);
+ } else {
+ host = value;
+ }
+ }
+ if (host.isEmpty()) {
+ throw new IllegalArgumentException("URL pattern has no hostname");
+ }
+ if (!port.isEmpty() && !containsComponentPatternSyntax(port)) {
+ final int number;
+ try {
+ number = Integer.parseInt(port);
+ } catch (final NumberFormatException ex) {
+ throw new IllegalArgumentException("Invalid URL pattern port", ex);
+ }
+ if (number < 0 || number > 65535) {
+ throw new IllegalArgumentException("Invalid URL pattern port");
+ }
+ if (number == defaultPort(protocol)) {
+ port = "";
+ }
+ }
+ final String normalizedHost = containsComponentPatternSyntax(host)
+ ? host.toLowerCase(Locale.ROOT)
+ : normalizeHost(host);
+ return new HostPort(normalizedHost, port);
+ }
+
+ private static int portDelimiter(final String value) {
+ final int namedPort = value.indexOf("::");
+ if (namedPort > 0) {
+ return namedPort;
+ }
+ final int colon = value.lastIndexOf(':');
+ if (colon <= 0 || colon + 1 >= value.length()) {
+ return -1;
+ }
+ final char first = value.charAt(colon + 1);
+ return isDigit(first) || first == '*' || first == '{'
+ ? colon
+ : -1;
+ }
+ }
+
+ private enum Component {
+ DEFAULT('\0', false),
+ PROTOCOL('\0', true),
+ HOSTNAME('.', true),
+ PATHNAME('/', false);
+
+ final char delimiter;
+ final boolean lowerCase;
+
+ Component(final char delimiter, final boolean lowerCase) {
+ this.delimiter = delimiter;
+ this.lowerCase = lowerCase;
+ }
+ }
+
+ private static Pattern compileComponent(final String input, final Component component) {
+ if (input == null) {
+ return null;
+ }
+ try {
+ final Set names = new HashSet<>();
+ final String regex = new ComponentCompiler(input, component, names).compile();
+ return Pattern.compile("^(?:" + regex + ")$");
+ } catch (final PatternSyntaxException ex) {
+ throw new IllegalArgumentException("Invalid URL pattern", ex);
+ }
+ }
+
+ /** Compiles URLPattern groups without accepting custom regular expressions. */
+ private static final class ComponentCompiler {
+
+ private final String input;
+ private final Component component;
+ private final Set names;
+ private int pos;
+
+ ComponentCompiler(
+ final String input,
+ final Component component,
+ final Set names) {
+ this.input = component.lowerCase ? input.toLowerCase(Locale.ROOT) : input;
+ this.component = component;
+ this.names = names;
+ }
+
+ String compile() {
+ final String result = compileSequence(false);
+ if (pos != input.length()) {
+ throw new IllegalArgumentException("Unexpected URL pattern group terminator");
+ }
+ return result;
+ }
+
+ private String compileSequence(final boolean grouped) {
+ final StringBuilder result = new StringBuilder();
+ final StringBuilder literal = new StringBuilder();
+ while (pos < input.length()) {
+ final char ch = input.charAt(pos++);
+ if (ch == '\\') {
+ if (pos >= input.length()) {
+ throw new IllegalArgumentException("Dangling URL pattern escape");
+ }
+ literal.append(input.charAt(pos++));
+ } else if (ch == '}') {
+ if (!grouped) {
+ throw new IllegalArgumentException("Unexpected URL pattern group terminator");
+ }
+ appendLiteral(result, literal);
+ return result.toString();
+ } else if (ch == '{') {
+ appendLiteral(result, literal);
+ final String body = compileSequence(true);
+ appendModified(result, body, modifier());
+ } else if (ch == ':') {
+ final int start = pos;
+ while (pos < input.length() && isNameChar(input.charAt(pos))) {
+ pos++;
+ }
+ if (start == pos || !isNameStart(input.charAt(start))) {
+ throw new IllegalArgumentException("URL pattern group has no name");
+ }
+ final String name = input.substring(start, pos);
+ if (!names.add(name)) {
+ throw new IllegalArgumentException("Duplicate URL pattern group name");
+ }
+ if (pos < input.length() && input.charAt(pos) == '(') {
+ throw new IllegalArgumentException("Regular-expression groups are not permitted");
+ }
+ final char modifier = modifier();
+ final String prefix = detachPrefix(literal, modifier);
+ appendLiteral(result, literal);
+ final String body = prefix + defaultGroupRegex(component.delimiter);
+ appendModified(result, body, modifier);
+ } else if (ch == '*') {
+ appendLiteral(result, literal);
+ appendModified(result, ".*", modifier());
+ } else if (ch == '(' || ch == ')') {
+ throw new IllegalArgumentException("Regular-expression groups are not permitted");
+ } else if (ch == '?' || ch == '+') {
+ throw new IllegalArgumentException("URL pattern modifier has no group");
+ } else {
+ literal.append(ch);
+ }
+ }
+ if (grouped) {
+ throw new IllegalArgumentException("Unterminated URL pattern group");
+ }
+ appendLiteral(result, literal);
+ return result.toString();
+ }
+
+ private char modifier() {
+ if (pos < input.length()) {
+ final char ch = input.charAt(pos);
+ if (ch == '?' || ch == '*' || ch == '+') {
+ pos++;
+ return ch;
+ }
+ }
+ return '\0';
+ }
+
+ private String detachPrefix(final StringBuilder literal, final char modifier) {
+ if (modifier != '\0'
+ && component.delimiter != '\0'
+ && literal.length() > 0
+ && literal.charAt(literal.length() - 1) == component.delimiter) {
+ literal.setLength(literal.length() - 1);
+ return Pattern.quote(String.valueOf(component.delimiter));
+ }
+ return "";
+ }
+
+ private static void appendLiteral(
+ final StringBuilder result,
+ final StringBuilder literal) {
+ if (literal.length() > 0) {
+ result.append(Pattern.quote(literal.toString()));
+ literal.setLength(0);
+ }
+ }
+
+ private static void appendModified(
+ final StringBuilder result,
+ final String body,
+ final char modifier) {
+ result.append("(?:").append(body).append(')');
+ if (modifier != '\0') {
+ result.append(modifier);
+ }
+ }
+ }
+
+ private static String defaultGroupRegex(final char delimiter) {
+ return delimiter == '\0'
+ ? ".+?"
+ : "[^" + delimiter + "]+?";
+ }
+
+ private static void rejectRegexpGroups(final String value) {
+ boolean escaped = false;
+ for (int i = 0; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (ch == '\\') {
+ escaped = true;
+ } else if (ch == '(' || ch == ')') {
+ throw new IllegalArgumentException("Regular-expression groups are not permitted");
+ }
+ }
+ if (escaped) {
+ throw new IllegalArgumentException("Dangling URL pattern escape");
+ }
+ }
+
+ private static int findAuthorityMarker(final String value) {
+ int braces = 0;
+ boolean escaped = false;
+ for (int i = 0; i + 2 < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (ch == '\\') {
+ escaped = true;
+ } else if (ch == '{') {
+ braces++;
+ } else if (ch == '}') {
+ braces--;
+ } else if (braces == 0
+ && ch == ':'
+ && value.charAt(i + 1) == '/'
+ && value.charAt(i + 2) == '/') {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static int findTailStart(final String value, final int start) {
+ int braces = 0;
+ boolean escaped = false;
+ for (int i = start; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (ch == '\\') {
+ escaped = true;
+ } else if (ch == '{') {
+ braces++;
+ } else if (ch == '}') {
+ braces--;
+ } else if (braces == 0
+ && (ch == '/' || ch == '#'
+ || ch == '?' && !isGroupModifier(value, i))) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static int findDelimiter(final String value, final char delimiter) {
+ int braces = 0;
+ boolean escaped = false;
+ for (int i = 0; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (ch == '\\') {
+ escaped = true;
+ } else if (ch == '{') {
+ braces++;
+ } else if (ch == '}') {
+ braces--;
+ } else if (braces == 0 && ch == delimiter) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static int findQueryDelimiter(final String value) {
+ int braces = 0;
+ boolean escaped = false;
+ for (int i = 0; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (ch == '\\' && i + 1 < value.length()
+ && value.charAt(i + 1) == '?') {
+ return i + 1;
+ } else if (ch == '\\') {
+ escaped = true;
+ } else if (ch == '{') {
+ braces++;
+ } else if (ch == '}') {
+ braces--;
+ } else if (braces == 0 && ch == '?' && !isGroupModifier(value, i)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static boolean isGroupModifier(final String value, final int questionIndex) {
+ if (questionIndex == 0) {
+ return false;
+ }
+ final char previous = value.charAt(questionIndex - 1);
+ if (previous == '}' || previous == '*') {
+ return true;
+ }
+ int i = questionIndex - 1;
+ while (i >= 0 && isNameChar(value.charAt(i))) {
+ i--;
+ }
+ return i >= 0
+ && value.charAt(i) == ':'
+ && i + 1 < questionIndex
+ && isNameStart(value.charAt(i + 1));
+ }
+
+ private static boolean containsComponentPatternSyntax(final String value) {
+ boolean escaped = false;
+ for (int i = 0; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (ch == '\\') {
+ escaped = true;
+ } else if (ch == '*' || ch == ':' || ch == '{' || ch == '}'
+ || ch == '(' || ch == ')' || ch == '?' || ch == '+') {
+ return true;
+ }
+ }
+ return escaped;
+ }
+
+ private static boolean isScheme(final String value) {
+ if (value.isEmpty() || !isAlpha(value.charAt(0))) {
+ return false;
+ }
+ for (int i = 1; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (!isAlpha(ch) && !isDigit(ch) && ch != '+' && ch != '-' && ch != '.') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean isNameChar(final char ch) {
+ return isAlpha(ch) || isDigit(ch) || ch == '_';
+ }
+
+ private static boolean isNameStart(final char ch) {
+ return isAlpha(ch) || ch == '_';
+ }
+
+ private static boolean isAlpha(final char ch) {
+ return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z';
+ }
+
+ private static boolean isDigit(final char ch) {
+ return ch >= '0' && ch <= '9';
+ }
+
+ private static int defaultPort(final String scheme) {
+ return "https".equalsIgnoreCase(scheme) ? 443
+ : "http".equalsIgnoreCase(scheme) ? 80 : -1;
+ }
+
+ private static String normalizedPort(final URI uri) {
+ return uri.getPort() < 0 || uri.getPort() == defaultPort(uri.getScheme())
+ ? ""
+ : Integer.toString(uri.getPort());
+ }
+
+ private static String rawPath(final URI uri) {
+ final String value = URI.create(uri.toASCIIString()).normalize().getRawPath();
+ return value == null || value.isEmpty() ? "/" : value;
+ }
+
+ private static String normalizePath(final String value) {
+ final boolean absolute = value.startsWith("/");
+ final String[] segments = value.split("/", -1);
+ final List normalized = new ArrayList<>();
+ for (int i = absolute ? 1 : 0; i < segments.length; i++) {
+ final String segment = segments[i];
+ if (isSingleDot(segment)) {
+ continue;
+ }
+ if (isDoubleDot(segment)) {
+ if (!normalized.isEmpty()) {
+ normalized.remove(normalized.size() - 1);
+ }
+ } else {
+ normalized.add(segment);
+ }
+ }
+ final StringBuilder result = new StringBuilder(value.length());
+ if (absolute) {
+ result.append('/');
+ }
+ for (int i = 0; i < normalized.size(); i++) {
+ if (i > 0) {
+ result.append('/');
+ }
+ result.append(normalized.get(i));
+ }
+ return result.length() > 0 ? result.toString() : absolute ? "/" : "";
+ }
+
+ private static boolean isSingleDot(final String value) {
+ return ".".equals(value) || "%2e".equalsIgnoreCase(value);
+ }
+
+ private static boolean isDoubleDot(final String value) {
+ return "..".equals(value)
+ || ".%2e".equalsIgnoreCase(value)
+ || "%2e.".equalsIgnoreCase(value)
+ || "%2e%2e".equalsIgnoreCase(value);
+ }
+
+ private static String valueOrEmpty(final String value) {
+ return value != null ? value : "";
+ }
+
+ private static final class UserInfo {
+ final String username;
+ final String password;
+
+ UserInfo(final String username, final String password) {
+ this.username = username;
+ this.password = password;
+ }
+
+ static UserInfo from(final URI uri) {
+ final String value = uri.getRawUserInfo();
+ if (value == null) {
+ return new UserInfo("", "");
+ }
+ final int colon = value.indexOf(':');
+ return colon >= 0
+ ? new UserInfo(value.substring(0, colon), value.substring(colon + 1))
+ : new UserInfo(value, "");
+ }
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/ContentCompressionAsyncExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/ContentCompressionAsyncExec.java
index cf29083429..dc0bd11656 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/ContentCompressionAsyncExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/ContentCompressionAsyncExec.java
@@ -27,10 +27,14 @@
package org.apache.hc.client5.http.impl.async;
import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.function.UnaryOperator;
@@ -39,8 +43,13 @@
import org.apache.hc.client5.http.async.AsyncExecChainHandler;
import org.apache.hc.client5.http.async.methods.InflatingAsyncDataConsumer;
import org.apache.hc.client5.http.async.methods.InflatingBrotliDataConsumer;
+import org.apache.hc.client5.http.async.methods.InflatingDictionaryBrotliDataConsumer;
+import org.apache.hc.client5.http.async.methods.InflatingDictionaryZstdDataConsumer;
import org.apache.hc.client5.http.async.methods.InflatingGzipDataConsumer;
import org.apache.hc.client5.http.async.methods.InflatingZstdDataConsumer;
+import org.apache.hc.client5.http.cookie.CookieStore;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionaryStore;
import org.apache.hc.client5.http.entity.compress.ContentCoding;
import org.apache.hc.client5.http.impl.Brotli4jRuntime;
import org.apache.hc.client5.http.impl.ContentCodingSupport;
@@ -50,10 +59,12 @@
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.annotation.ThreadingBehavior;
import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.ParseException;
import org.apache.hc.core5.http.config.Lookup;
import org.apache.hc.core5.http.config.RegistryBuilder;
import org.apache.hc.core5.http.message.MessageSupport;
@@ -65,20 +76,67 @@
@Internal
public final class ContentCompressionAsyncExec implements AsyncExecChainHandler {
+ private static final int DEFAULT_MAX_DICTIONARY_SIZE = 16 * 1024 * 1024;
+
private final Lookup> decoders;
private final List acceptTokens;
+ private final List dictionaryAcceptTokens;
private final int maxCodecListLen;
+ private final CompressionDictionaryStore compressionDictionaryStore;
+ private final CompressionDictionaryMatcher compressionDictionaryMatcher;
public ContentCompressionAsyncExec(
final LinkedHashMap> decoderMap,
- final int maxCodecListLen) {
+ final int maxCodecListLen,
+ final CompressionDictionaryStore compressionDictionaryStore) {
Args.notEmpty(decoderMap, "Decoder map");
final RegistryBuilder> rb = RegistryBuilder.create();
decoderMap.forEach(rb::register);
+
+ final List tokens = new ArrayList<>();
+ decoderMap.keySet().forEach(token -> {
+ if (!ContentCoding.DCB.token().equalsIgnoreCase(token)
+ && !ContentCoding.DCZ.token().equalsIgnoreCase(token)) {
+ tokens.add(token);
+ }
+ });
+
+ final List dictionaryTokens = new ArrayList<>();
+ if (compressionDictionaryStore != null) {
+ if (containsToken(decoderMap, ContentCoding.DCB.token())) {
+ dictionaryTokens.add(ContentCoding.DCB.token());
+ } else if (Brotli4jRuntime.available()) {
+ dictionaryTokens.add(ContentCoding.DCB.token());
+ }
+
+ if (containsToken(decoderMap, ContentCoding.DCZ.token())) {
+ dictionaryTokens.add(ContentCoding.DCZ.token());
+ } else if (ZstdRuntime.available()) {
+ dictionaryTokens.add(ContentCoding.DCZ.token());
+ }
+ }
+
this.decoders = rb.build();
- this.acceptTokens = new ArrayList<>(decoderMap.keySet());
+ this.acceptTokens = tokens;
+ this.dictionaryAcceptTokens = dictionaryTokens;
this.maxCodecListLen = maxCodecListLen;
+ this.compressionDictionaryStore = compressionDictionaryStore;
+ this.compressionDictionaryMatcher = compressionDictionaryStore != null
+ ? new DefaultCompressionDictionaryMatcher()
+ : null;
+ }
+
+ public ContentCompressionAsyncExec(
+ final LinkedHashMap> decoderMap,
+ final int maxCodecListLen) {
+ this(decoderMap, maxCodecListLen, null);
+ }
+
+ public ContentCompressionAsyncExec(
+ final LinkedHashMap> decoderMap,
+ final CompressionDictionaryStore compressionDictionaryStore) {
+ this(decoderMap, ContentCodingSupport.MAX_CODEC_LIST_LEN, compressionDictionaryStore);
}
public ContentCompressionAsyncExec(
@@ -89,7 +147,9 @@ public ContentCompressionAsyncExec(
/**
* Default: DEFLATE + GZIP (plus x-gzip alias).
*/
- public ContentCompressionAsyncExec(final int maxCodecListLen) {
+ public ContentCompressionAsyncExec(
+ final int maxCodecListLen,
+ final CompressionDictionaryStore compressionDictionaryStore) {
final LinkedHashMap> map = new LinkedHashMap<>();
map.put(ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
map.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
@@ -113,9 +173,34 @@ public ContentCompressionAsyncExec(final int maxCodecListLen) {
tokens.add(ContentCoding.BROTLI.token());
}
+ final List dictionaryTokens = new ArrayList<>();
+ if (compressionDictionaryStore != null) {
+ if (Brotli4jRuntime.available()) {
+ dictionaryTokens.add(ContentCoding.DCB.token());
+ }
+
+ if (ZstdRuntime.available()) {
+ dictionaryTokens.add(ContentCoding.DCZ.token());
+ }
+ }
+
this.decoders = rb.build();
this.acceptTokens = tokens;
+ this.dictionaryAcceptTokens = dictionaryTokens;
this.maxCodecListLen = maxCodecListLen;
+ this.compressionDictionaryStore = compressionDictionaryStore;
+ this.compressionDictionaryMatcher = compressionDictionaryStore != null
+ ? new DefaultCompressionDictionaryMatcher()
+ : null;
+ }
+
+ public ContentCompressionAsyncExec(final int maxCodecListLen) {
+ this(maxCodecListLen, null);
+ }
+
+ public ContentCompressionAsyncExec(
+ final CompressionDictionaryStore compressionDictionaryStore) {
+ this(ContentCodingSupport.MAX_CODEC_LIST_LEN, compressionDictionaryStore);
}
public ContentCompressionAsyncExec() {
@@ -132,9 +217,36 @@ public void execute(
final HttpClientContext ctx = scope != null ? scope.clientContext : HttpClientContext.create();
final boolean enabled = ctx.getRequestConfigOrDefault().isContentCompressionEnabled();
+ final URI requestUri = resolveRequestUri(request, scope);
+ final Instant requestTime = Instant.now();
+ final CookieStore privacyPartition = getPrivacyPartition(ctx);
+
+ final CompressionDictionary dictionary = enabled
+ ? findDictionary(request, requestUri, privacyPartition)
+ : null;
+
+ if (dictionary != null) {
+ request.addHeader(
+ CompressionDictionaryHeaderSupport.AVAILABLE_DICTIONARY,
+ CompressionDictionaryHeaderSupport.formatAvailableDictionary(dictionary.getSha256()));
+
+ if (!dictionary.getId().isEmpty()) {
+ request.addHeader(
+ CompressionDictionaryHeaderSupport.DICTIONARY_ID,
+ CompressionDictionaryHeaderSupport.formatDictionaryId(dictionary.getId()));
+ }
+ }
if (enabled && !request.containsHeader(HttpHeaders.ACCEPT_ENCODING)) {
- request.addHeader(MessageSupport.headerOfTokens(HttpHeaders.ACCEPT_ENCODING, acceptTokens));
+ if (dictionary != null && !dictionaryAcceptTokens.isEmpty()) {
+ final List tokens = new ArrayList<>(
+ acceptTokens.size() + dictionaryAcceptTokens.size());
+ tokens.addAll(acceptTokens);
+ tokens.addAll(dictionaryAcceptTokens);
+ request.addHeader(MessageSupport.headerOfTokens(HttpHeaders.ACCEPT_ENCODING, tokens));
+ } else {
+ request.addHeader(MessageSupport.headerOfTokens(HttpHeaders.ACCEPT_ENCODING, acceptTokens));
+ }
}
chain.proceed(request, producer, scope, new AsyncExecCallback() {
@@ -148,6 +260,15 @@ public AsyncDataConsumer handleResponse(final HttpResponse rsp,
return cb.handleResponse(rsp, details);
}
+ final UseAsDictionary useAsDictionary =
+ parseUseAsDictionary(rsp, requestUri, privacyPartition);
+
+ final Instant responseTime = Instant.now();
+ final Instant storedAt = useAsDictionary != null ? responseTime : null;
+ final Instant validUntil = storedAt != null
+ ? CompressionDictionaryFreshness.determineValidUntil(rsp, requestTime, responseTime)
+ : null;
+
final List codecs = ContentCodingSupport.parseContentCodecs(details);
ContentCodingSupport.validate(codecs, maxCodecListLen);
if (!codecs.isEmpty()) {
@@ -155,19 +276,70 @@ public AsyncDataConsumer handleResponse(final HttpResponse rsp,
if (downstream == null) {
return null;
}
+
+ if (useAsDictionary != null && validUntil != null) {
+ downstream = new DictionaryCapturingAsyncDataConsumer(
+ downstream,
+ compressionDictionaryStore,
+ privacyPartition,
+ requestUri,
+ useAsDictionary,
+ storedAt,
+ validUntil,
+ DEFAULT_MAX_DICTIONARY_SIZE);
+ }
+
for (int i = codecs.size() - 1; i >= 0; i--) {
final String codec = codecs.get(i);
- final UnaryOperator op = decoders.lookup(codec);
- if (op != null) {
- downstream = op.apply(downstream);
+
+ if ((ContentCoding.DCB.token().equalsIgnoreCase(codec)
+ || ContentCoding.DCZ.token().equalsIgnoreCase(codec))
+ && dictionary == null) {
+ throw new HttpException(
+ "Dictionary Content-Encoding without negotiated dictionary: " + codec);
+ }
+
+ if (ContentCoding.DCB.token().equalsIgnoreCase(codec)) {
+ if (!dictionaryAcceptTokens.contains(ContentCoding.DCB.token())) {
+ throw new HttpException("Unsupported Content-Encoding: " + codec);
+ }
+ final UnaryOperator op = decoders.lookup(codec);
+ downstream = op != null
+ ? op.apply(downstream)
+ : new InflatingDictionaryBrotliDataConsumer(downstream, dictionary);
+ } else if (ContentCoding.DCZ.token().equalsIgnoreCase(codec)) {
+ if (!dictionaryAcceptTokens.contains(ContentCoding.DCZ.token())) {
+ throw new HttpException("Unsupported Content-Encoding: " + codec);
+ }
+ final UnaryOperator op = decoders.lookup(codec);
+ downstream = op != null
+ ? op.apply(downstream)
+ : new InflatingDictionaryZstdDataConsumer(downstream, dictionary);
} else {
- throw new HttpException("Unsupported Content-Encoding: " + codec);
+ final UnaryOperator op = decoders.lookup(codec);
+ if (op != null) {
+ downstream = op.apply(downstream);
+ } else {
+ throw new HttpException("Unsupported Content-Encoding: " + codec);
+ }
}
}
return downstream;
}
- return cb.handleResponse(rsp, details);
+ AsyncDataConsumer downstream = cb.handleResponse(rsp, details);
+ if (downstream != null && useAsDictionary != null && validUntil != null) {
+ downstream = new DictionaryCapturingAsyncDataConsumer(
+ downstream,
+ compressionDictionaryStore,
+ privacyPartition,
+ requestUri,
+ useAsDictionary,
+ storedAt,
+ validUntil,
+ DEFAULT_MAX_DICTIONARY_SIZE);
+ }
+ return downstream;
}
@Override
@@ -188,6 +360,111 @@ public void failed(final Exception ex) {
});
}
+ private CompressionDictionary findDictionary(
+ final HttpRequest request,
+ final URI requestUri,
+ final CookieStore privacyPartition) {
+ if (compressionDictionaryStore == null
+ || compressionDictionaryMatcher == null
+ || privacyPartition == null
+ || requestUri == null
+ || !"https".equalsIgnoreCase(requestUri.getScheme())
+ || request.containsHeader(CompressionDictionaryHeaderSupport.AVAILABLE_DICTIONARY)
+ || request.containsHeader(CompressionDictionaryHeaderSupport.DICTIONARY_ID)) {
+ return null;
+ }
+
+ return compressionDictionaryMatcher.match(
+ requestUri,
+ null,
+ compressionDictionaryStore.getByOrigin(privacyPartition, requestUri));
+ }
+
+ private UseAsDictionary parseUseAsDictionary(
+ final HttpResponse response,
+ final URI requestUri,
+ final CookieStore privacyPartition) {
+ if (compressionDictionaryStore == null
+ || privacyPartition == null
+ || requestUri == null
+ || !"https".equalsIgnoreCase(requestUri.getScheme())) {
+ return null;
+ }
+
+ final Header[] headers = response.getHeaders(CompressionDictionaryHeaderSupport.USE_AS_DICTIONARY);
+ if (headers == null || headers.length == 0) {
+ return null;
+ }
+
+ final StringBuilder value = new StringBuilder();
+ for (final Header header : headers) {
+ if (value.length() > 0) {
+ value.append(',');
+ }
+ value.append(header.getValue());
+ }
+
+ try {
+ final UseAsDictionary useAsDictionary = UseAsDictionary.parse(value.toString());
+ if (!useAsDictionary.isSupported()
+ || !new DefaultCompressionDictionaryUrlPatternMatcher().isValid(
+ useAsDictionary.getMatch(), requestUri)) {
+ return null;
+ }
+ return useAsDictionary;
+ } catch (final ParseException | IllegalArgumentException ex) {
+ return null;
+ }
+ }
+
+ private CookieStore getPrivacyPartition(final HttpClientContext context) {
+ final CookieStore cookieStore = context.getCookieStore();
+ if (cookieStore instanceof CompressionDictionaryCookieStore
+ && ((CompressionDictionaryCookieStore) cookieStore)
+ .isBoundTo(compressionDictionaryStore)) {
+ return cookieStore;
+ }
+ return null;
+ }
+
+ private static boolean containsToken(
+ final Map map,
+ final String expected) {
+ for (final String token : map.keySet()) {
+ if (expected.equalsIgnoreCase(token)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static URI resolveRequestUri(
+ final HttpRequest request,
+ final AsyncExecChain.Scope scope) {
+ try {
+ if (scope != null) {
+ final URI originalUri = scope.originalRequest.getUri();
+ if (originalUri.isAbsolute()) {
+ return originalUri;
+ }
+ }
+
+ final URI requestUri = request.getUri();
+ if (requestUri.isAbsolute()) {
+ return requestUri;
+ }
+
+ if (scope != null) {
+ final URI baseUri = URI.create(scope.route.getTargetHost().toURI() + "/");
+ return baseUri.resolve(requestUri);
+ }
+
+ return null;
+ } catch (final URISyntaxException | IllegalArgumentException ex) {
+ return null;
+ }
+ }
+
private static EntityDetails wrapEntityDetails(final EntityDetails original) {
return new EntityDetails() {
@Override
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/DefaultCompressionDictionaryMatcher.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/DefaultCompressionDictionaryMatcher.java
new file mode 100644
index 0000000000..1fd92495dc
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/DefaultCompressionDictionaryMatcher.java
@@ -0,0 +1,254 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.net.URI;
+import java.time.Clock;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Comparator;
+
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Default {@link CompressionDictionaryMatcher} that selects the compression dictionary
+ * to advertise for an outgoing request, following the matching and selection rules of
+ * Compression Dictionary Transport.
+ *
+ * A candidate is eligible only when it is still fresh at the current instant, shares the
+ * same origin as the request and its stored URL pattern matches the request path. Dictionary
+ * transport is restricted to secure origins, so a request whose scheme is not {@code https}
+ * never matches, and a candidate whose source is not an {@code https} URL is ignored.
+ *
+ * When several candidates are eligible the most specific one wins: the longest pattern is
+ * preferred, and ties are broken in favour of the most recently stored dictionary. The
+ * winning dictionary is the value the caller offers to the origin through the
+ * {@code Available-Dictionary} header.
+ *
+ * Instances are immutable and stateless apart from the injected {@link Clock}, hence thread
+ * safe and reusable across requests. The clock constructor exists for deterministic testing;
+ * production code uses the system UTC clock.
+ */
+final class DefaultCompressionDictionaryMatcher
+ implements CompressionDictionaryMatcher {
+
+ private final Clock clock;
+ private final CompressionDictionaryUrlPatternMatcher urlPatternMatcher;
+
+ /**
+ * Creates a matcher backed by the system UTC clock and the default URL pattern matcher.
+ */
+ DefaultCompressionDictionaryMatcher() {
+ this(Clock.systemUTC(), new DefaultCompressionDictionaryUrlPatternMatcher());
+ }
+
+ /**
+ * Creates a matcher with an explicit clock, used to make freshness evaluation deterministic
+ * in tests.
+ *
+ * @param clock the clock supplying the instant against which candidate freshness is judged.
+ */
+ DefaultCompressionDictionaryMatcher(final Clock clock) {
+ this(clock, new DefaultCompressionDictionaryUrlPatternMatcher());
+ }
+
+ /**
+ * Creates a matcher with an explicit URL pattern matcher over the system UTC clock.
+ *
+ * @param urlPatternMatcher the strategy that validates and evaluates a candidate's stored
+ * {@code match} pattern against the request.
+ */
+ DefaultCompressionDictionaryMatcher(
+ final CompressionDictionaryUrlPatternMatcher urlPatternMatcher) {
+ this(Clock.systemUTC(), urlPatternMatcher);
+ }
+
+ /**
+ * Creates a matcher with both collaborators supplied explicitly.
+ *
+ * @param clock the clock supplying the instant against which candidate freshness is judged.
+ * @param urlPatternMatcher the strategy that validates and evaluates a candidate's stored
+ * {@code match} pattern against the request.
+ * @throws NullPointerException if either argument is {@code null}.
+ */
+ DefaultCompressionDictionaryMatcher(
+ final Clock clock,
+ final CompressionDictionaryUrlPatternMatcher urlPatternMatcher) {
+ this.clock = Args.notNull(clock, "Clock");
+ this.urlPatternMatcher =
+ Args.notNull(urlPatternMatcher, "URL pattern matcher");
+ }
+
+ /**
+ * Convenience overload for callers that do not carry a request destination; equivalent to
+ * {@link #match(URI, String, Collection)} with a {@code null} destination.
+ *
+ * @param requestUri the absolute request target.
+ * @param dictionaries the stored dictionaries to consider.
+ * @return the winning dictionary, or {@code null} when no candidate is eligible.
+ */
+ CompressionDictionary match(
+ final URI requestUri,
+ final Collection dictionaries) {
+ return match(requestUri, null, dictionaries);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * A candidate survives only when it is fresh at the clock instant, is a {@code raw}
+ * dictionary, shares the request origin, applies to the request destination and carries a
+ * {@code match} pattern that is valid for its source and matches the request. Among the
+ * survivors the winner is chosen by three keys in order: a {@code match-dest} that names the
+ * request destination outranks a destination-agnostic one, then the longest {@code match}
+ * pattern wins, and finally the most recently stored dictionary breaks any remaining tie.
+ *
+ * @throws NullPointerException if {@code requestUri} or {@code dictionaries} is {@code null}.
+ */
+ @Override
+ public CompressionDictionary match(
+ final URI requestUri,
+ final String requestDestination,
+ final Collection dictionaries) {
+
+ Args.notNull(requestUri, "Request URI");
+ Args.notNull(dictionaries, "Dictionaries");
+
+ if (!"https".equalsIgnoreCase(requestUri.getScheme())
+ || dictionaries.isEmpty()) {
+ return null;
+ }
+
+ final Instant now = clock.instant();
+
+ return dictionaries.stream()
+ .filter(dictionary -> dictionary.isFresh(now))
+ .filter(dictionary -> "raw".equals(dictionary.getType()))
+ .filter(dictionary -> sameOrigin(
+ dictionary.getSource(), requestUri))
+ .filter(dictionary -> destinationMatches(
+ dictionary, requestDestination))
+ .filter(dictionary -> urlPatternMatcher.isValid(
+ dictionary.getMatch(), dictionary.getSource()))
+ .filter(dictionary -> urlPatternMatcher.matches(
+ dictionary.getMatch(),
+ dictionary.getSource(),
+ requestUri))
+ .max(
+ Comparator
+ .comparingInt(
+ (CompressionDictionary dictionary) ->
+ destinationPrecedence(
+ dictionary,
+ requestDestination))
+ .thenComparingInt(
+ dictionary ->
+ dictionary.getMatch().length())
+ .thenComparing(
+ CompressionDictionary::getStoredAt))
+ .orElse(null);
+ }
+
+ /**
+ * Ranks a candidate for tie-breaking: a dictionary whose {@code match-dest} explicitly names
+ * the request destination is preferred over one that matches irrespective of destination.
+ * Returns {@code 1} for such an explicit destination match and {@code 0} otherwise, including
+ * when the caller carries no destination or the candidate constrains none.
+ */
+ private static int destinationPrecedence(
+ final CompressionDictionary dictionary,
+ final String requestDestination) {
+
+ if (requestDestination == null
+ || dictionary.getMatchDest().isEmpty()) {
+ return 0;
+ }
+
+ return dictionary.getMatchDest().contains(requestDestination)
+ ? 1
+ : 0;
+ }
+
+ /**
+ * Tests whether a candidate's {@code match-dest} admits the request destination. An empty
+ * {@code match-dest} places no constraint and admits every destination.
+ */
+ private static boolean destinationMatches(
+ final CompressionDictionary dictionary,
+ final String requestDestination) {
+
+ if (requestDestination == null) {
+ /*
+ * Compression Dictionary Transport: a client that does not support request
+ * destinations treats match-dest as an empty list, so the constraint is waived.
+ */
+ return true;
+ }
+
+ return dictionary.getMatchDest().isEmpty()
+ || dictionary.getMatchDest().contains(requestDestination);
+ }
+
+ /**
+ * Tests whether two URIs denote the same origin, that is the same scheme, host and effective
+ * port. Dictionary transport is scoped to a single origin, so a candidate stored for one
+ * origin is never advertised on a request to another.
+ */
+ private static boolean sameOrigin(
+ final URI first,
+ final URI second) {
+
+ return equalsIgnoreCase(first.getScheme(), second.getScheme())
+ && equalsIgnoreCase(first.getHost(), second.getHost())
+ && effectivePort(first) == effectivePort(second);
+ }
+
+ private static boolean equalsIgnoreCase(
+ final String first,
+ final String second) {
+ return first != null
+ && second != null
+ && first.equalsIgnoreCase(second);
+ }
+
+ /**
+ * Resolves the port that participates in origin comparison, substituting the scheme default
+ * when the URI carries no explicit port: {@code 443} for {@code https} and {@code 80} for
+ * {@code http}. Any other scheme with no port yields {@code -1}.
+ */
+ private static int effectivePort(final URI uri) {
+ if (uri.getPort() >= 0) {
+ return uri.getPort();
+ }
+ return "https".equalsIgnoreCase(uri.getScheme())
+ ? 443
+ : "http".equalsIgnoreCase(uri.getScheme())
+ ? 80
+ : -1;
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/DictionaryCapturingAsyncDataConsumer.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/DictionaryCapturingAsyncDataConsumer.java
new file mode 100644
index 0000000000..ad32e8d8ad
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/DictionaryCapturingAsyncDataConsumer.java
@@ -0,0 +1,208 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.hc.client5.http.cookie.CookieStore;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionaryStore;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * {@link AsyncDataConsumer} decorator that captures a response body so it can be
+ * registered as a compression dictionary while the body is delivered to the wrapped
+ * consumer unchanged.
+ *
+ * When the origin marks a response with a {@code Use-As-Dictionary} directive, that
+ * response may be reused as a shared dictionary to decode future Dictionary-Compressed
+ * Brotli ({@code dcb}) or Dictionary-Compressed Zstandard ({@code dcz}) responses, as
+ * defined by Compression Dictionary Transport. This decorator sits between the message pipeline and the actual
+ * body consumer: every chunk is forwarded downstream first, then a copy of the bytes the
+ * downstream consumer accepted is accumulated into an in-memory buffer. On stream end the
+ * buffered bytes are handed to the {@link CompressionDictionaryStore} as a new
+ * {@link CompressionDictionary}, keyed by the request {@link URI} and the match pattern
+ * and identifier carried by the directive.
+ *
+ * Capture is best-effort and bounded. If the accumulated body would exceed {@code maxSize},
+ * or if the exchange is aborted before it completes, the buffer is discarded and no
+ * dictionary is stored; the downstream consumer is unaffected in either case. A dictionary
+ * is only stored when the directive is {@link UseAsDictionary#isSupported() supported}.
+ *
+ * Instances are not thread-safe and, like any {@link AsyncDataConsumer}, expect their
+ * callbacks to be invoked by a single I/O thread for the lifetime of one message exchange.
+ */
+final class DictionaryCapturingAsyncDataConsumer implements AsyncDataConsumer {
+
+ private final AsyncDataConsumer downstream;
+ private final CompressionDictionaryStore store;
+ private final CookieStore partition;
+ private final URI source;
+ private final UseAsDictionary directive;
+ private final Instant storedAt;
+ private final Instant validUntil;
+ private final int maxSize;
+ private final ByteArrayOutputStream buffer;
+
+ private boolean discarded;
+
+ /**
+ * Creates a capturing decorator for a single response whose {@code Use-As-Dictionary}
+ * offer has already been parsed. The arguments are validated eagerly: the references must
+ * be non-null and {@code maxSize} must be positive.
+ *
+ * @param downstream the body consumer to which every chunk is forwarded unchanged.
+ * @param store the store that receives the captured dictionary on a complete, supported delivery.
+ * @param partition the cookie storage partition associated with the exchange.
+ * @param source the request {@link URI} the dictionary is keyed by.
+ * @param directive the parsed {@code Use-As-Dictionary} offer supplying the match pattern,
+ * destination, identifier and type; a dictionary is stored only when the offer is
+ * {@link UseAsDictionary#isSupported() supported}.
+ * @param storedAt the instant recorded as the dictionary's creation time.
+ * @param validUntil the instant past which the stored dictionary is no longer valid.
+ * @param maxSize the capture ceiling in bytes; once the buffer would exceed it capture is
+ * abandoned for the rest of the exchange and no dictionary is stored.
+ */
+ DictionaryCapturingAsyncDataConsumer(
+ final AsyncDataConsumer downstream,
+ final CompressionDictionaryStore store,
+ final CookieStore partition,
+ final URI source,
+ final UseAsDictionary directive,
+ final Instant storedAt,
+ final Instant validUntil,
+ final int maxSize) {
+ this.downstream = Args.notNull(downstream, "Downstream data consumer");
+ this.store = Args.notNull(store, "Dictionary store");
+ this.partition = Args.notNull(partition, "Cookie partition");
+ this.source = Args.notNull(source, "Source");
+ this.directive = Args.notNull(directive, "Directive");
+ this.storedAt = Args.notNull(storedAt, "Stored at");
+ this.validUntil = Args.notNull(validUntil, "Valid until");
+ this.maxSize = Args.positive(maxSize, "Maximum dictionary size");
+ this.buffer = new ByteArrayOutputStream(Math.min(maxSize, 8192));
+ }
+
+ /**
+ * Propagates the capacity update unchanged; this decorator imposes no flow control of its
+ * own and leaves back-pressure entirely to the downstream consumer.
+ *
+ * @param capacityChannel the channel through which the downstream consumer signals the
+ * capacity it is prepared to accept.
+ * @throws IOException if the downstream consumer fails while handling the capacity update.
+ */
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ downstream.updateCapacity(capacityChannel);
+ }
+
+ /**
+ * Forwards the chunk downstream and, unless capture has been discarded, appends a copy
+ * of the bytes the downstream consumer actually accepted to the capture buffer. Only the
+ * range from the entry position to the position left by {@code downstream} is captured,
+ * so partially consumed buffers are handled correctly. Capture is abandoned for the rest
+ * of the exchange once the buffered size would exceed {@code maxSize}.
+ *
+ * @param src the received bytes; its position advanced by {@code downstream} marks the
+ * range that was accepted and is therefore captured.
+ * @throws IOException if the downstream consumer fails to accept the chunk.
+ */
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ final int start = src.position();
+
+ downstream.consume(src);
+
+ if (!discarded) {
+ final int consumed = src.position() - start;
+ if (consumed > 0) {
+ if (buffer.size() + consumed > maxSize) {
+ discarded = true;
+ buffer.reset();
+ return;
+ }
+
+ final ByteBuffer copy = src.duplicate();
+ copy.position(start);
+ copy.limit(start + consumed);
+
+ final byte[] bytes = new byte[consumed];
+ copy.get(bytes);
+ buffer.write(bytes, 0, bytes.length);
+ }
+ }
+ }
+
+ /**
+ * Signals stream end to the downstream consumer, then registers the captured body as a
+ * compression dictionary when capture completed intact and the directive is supported.
+ * A downstream failure prevents the response from being stored as a dictionary.
+ *
+ * @param trailers the trailing headers, forwarded unchanged to the downstream consumer.
+ * @throws HttpException if the downstream consumer rejects the end of stream.
+ * @throws IOException in case of an I/O error while signalling stream end downstream.
+ */
+ @Override
+ public void streamEnd(
+ final List extends Header> trailers)
+ throws HttpException, IOException {
+
+ downstream.streamEnd(trailers);
+
+ if (!discarded && directive.isSupported()) {
+ store.add(partition, new CompressionDictionary(
+ buffer.toByteArray(),
+ source,
+ directive.getMatch(),
+ directive.getMatchDest(),
+ directive.getId(),
+ directive.getType(),
+ storedAt,
+ validUntil));
+ }
+ }
+
+ /**
+ * Discards any captured bytes and releases the downstream consumer. Marking capture as
+ * discarded ensures no dictionary is stored for an exchange torn down before completion.
+ */
+ @Override
+ public void releaseResources() {
+ discarded = true;
+ buffer.reset();
+ downstream.releaseResources();
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java
index d010ac8617..8c607f5804 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java
@@ -53,6 +53,7 @@
import org.apache.hc.client5.http.cookie.BasicCookieStore;
import org.apache.hc.client5.http.cookie.CookieSpecFactory;
import org.apache.hc.client5.http.cookie.CookieStore;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionaryStore;
import org.apache.hc.client5.http.impl.ChainElement;
import org.apache.hc.client5.http.impl.CookieSpecSupport;
import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy;
@@ -262,6 +263,8 @@ private ExecInterceptorEntry(
*/
private LinkedHashMap> contentDecoderMap;
+ private CompressionDictionaryStore compressionDictionaryStore;
+
/**
* When {@code true} the client skips all transparent response decompression.
*/
@@ -883,6 +886,29 @@ public HttpAsyncClientBuilder setContentDecoderMap(
return this;
}
+ /**
+ * Sets the compression dictionary store used for Compression Dictionary
+ * Transport as defined by RFC 9842.
+ *
+ * When configured, matching dictionaries can be advertised and used for
+ * {@code dcb} and {@code dcz} response decompression. The default cookie
+ * store is bound to the dictionary store so clearing cookies also clears
+ * the corresponding dictionary partition. Dictionary transport is disabled
+ * when a custom cookie store is configured on the builder or supplied by an
+ * execution context, because its clearing lifecycle cannot be observed.
+ *
+ *
+ * @param compressionDictionaryStore the compression dictionary store,
+ * or {@code null} to disable dictionary transport
+ * @return {@code this} builder instance
+ * @since 5.7
+ */
+ public final HttpAsyncClientBuilder setCompressionDictionaryStore(
+ final CompressionDictionaryStore compressionDictionaryStore) {
+ this.compressionDictionaryStore = compressionDictionaryStore;
+ return this;
+ }
+
/**
* Disables transparent response decompression for the client produced by
* this builder.
@@ -1117,11 +1143,11 @@ public CloseableHttpAsyncClient build() {
if (!contentCompressionDisabled) {
if (contentDecoderMap != null && !contentDecoderMap.isEmpty()) {
execChainDefinition.addFirst(
- new ContentCompressionAsyncExec(contentDecoderMap),
+ new ContentCompressionAsyncExec(contentDecoderMap, compressionDictionaryStore),
ChainElement.COMPRESS.name());
} else {
execChainDefinition.addFirst(
- new ContentCompressionAsyncExec(),
+ new ContentCompressionAsyncExec(compressionDictionaryStore),
ChainElement.COMPRESS.name());
}
}
@@ -1249,6 +1275,10 @@ public CloseableHttpAsyncClient build() {
CookieStore cookieStoreCopy = this.cookieStore;
if (cookieStoreCopy == null) {
cookieStoreCopy = new BasicCookieStore();
+ if (compressionDictionaryStore != null) {
+ cookieStoreCopy = new CompressionDictionaryCookieStore(
+ cookieStoreCopy, compressionDictionaryStore);
+ }
}
CredentialsProvider credentialsProviderCopy = this.credentialsProvider;
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/UseAsDictionary.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/UseAsDictionary.java
new file mode 100644
index 0000000000..df850864db
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/UseAsDictionary.java
@@ -0,0 +1,679 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.hc.core5.http.ParseException;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Parsed form of the {@code Use-As-Dictionary} response header used by Compression Dictionary
+ * Transport. The header advertises that a response may serve as a compression dictionary for
+ * subsequent requests and is an HTTP Structured Field Dictionary carrying a required {@code match}
+ * URL pattern, an optional {@code match-dest} list of request destinations, an optional opaque
+ * {@code id} echoed back in {@code Dictionary-ID}, and a {@code type} token naming the dictionary
+ * format.
+ *
+ * Only the {@link #RAW raw} type is understood. An offer of any other type parses successfully but
+ * reports {@link #isSupported()} as {@code false}, so an unrecognised format renders the dictionary
+ * unusable rather than aborting the exchange.
+ */
+final class UseAsDictionary {
+
+ /**
+ * The single dictionary {@code type} this implementation honours, and the default when the
+ * {@code type} member is absent.
+ */
+ static final String RAW = "raw";
+
+ private final String match;
+ private final List matchDest;
+ private final String id;
+ private final String type;
+
+ /**
+ * Creates a parsed dictionary offer. {@code matchDest} is defensively copied and exposed as an
+ * unmodifiable list; a {@code null} list becomes empty, a {@code null} {@code id} becomes the
+ * empty string, and a {@code null} {@code type} defaults to {@link #RAW}.
+ *
+ * @param match the URL pattern the dictionary applies to.
+ * @param matchDest the request destinations the offer is restricted to, or {@code null} for none.
+ * @param id the opaque identifier echoed in {@code Dictionary-ID}, or {@code null} for none.
+ * @param type the dictionary format token, or {@code null} for {@link #RAW}.
+ */
+ UseAsDictionary(
+ final String match,
+ final List matchDest,
+ final String id,
+ final String type) {
+ this.match = match;
+ this.matchDest = matchDest != null
+ ? Collections.unmodifiableList(new ArrayList<>(matchDest))
+ : Collections.emptyList();
+ this.id = id != null ? id : "";
+ this.type = type != null ? type : RAW;
+ }
+
+ /**
+ * Parses a {@code Use-As-Dictionary} header value as an HTTP Structured Field Dictionary.
+ * Members other than {@code match}, {@code match-dest}, {@code id} and {@code type} are accepted
+ * and ignored, as are member parameters, so unknown extensions do not break parsing. A member
+ * whose value has the wrong Structured Field type for its key is rejected. The {@code match}
+ * member is mandatory; the others fall back to their defaults when absent.
+ *
+ * @param value the raw header value; must not be blank.
+ * @return the parsed offer.
+ * @throws ParseException if the value is malformed against the Structured Field grammar, if
+ * {@code match} is missing or not a non-empty String, if {@code match-dest} is not an inner
+ * list of Strings, if {@code id} is not a String of at most 1024 characters, or if
+ * {@code type} is not a Token.
+ */
+ static UseAsDictionary parse(final String value) throws ParseException {
+ Args.notBlank(value, "Use-As-Dictionary");
+ for (int i = 0; i < value.length(); i++) {
+ if (value.charAt(i) > 0x7f) {
+ throw new ParseException("Structured Field value is not ASCII");
+ }
+ }
+ final Parser parser = new Parser(value);
+ String match = null;
+ List matchDest = Collections.emptyList();
+ String id = "";
+ String type = RAW;
+
+ parser.skipSp();
+ while (!parser.atEnd()) {
+ final String key = parser.parseKey();
+ final Value member;
+ if (parser.consume('=')) {
+ member = parser.parseMemberValue();
+ } else {
+ member = Value.bool();
+ parser.parseParameters();
+ }
+
+ if ("match".equals(key)) {
+ if (member.kind != Value.STRING || member.stringValue.length() == 0) {
+ throw new ParseException("Invalid Use-As-Dictionary match member");
+ }
+ match = member.stringValue;
+ } else if ("match-dest".equals(key)) {
+ if (member.kind != Value.INNER_LIST || !member.stringList) {
+ throw new ParseException("Invalid Use-As-Dictionary match-dest member");
+ }
+ matchDest = member.listValue;
+ } else if ("id".equals(key)) {
+ if (member.kind != Value.STRING || member.stringValue.length() > 1024) {
+ throw new ParseException("Invalid Use-As-Dictionary id member");
+ }
+ id = member.stringValue;
+ } else if ("type".equals(key)) {
+ if (member.kind != Value.TOKEN) {
+ throw new ParseException("Invalid Use-As-Dictionary type member");
+ }
+ type = member.stringValue;
+ }
+
+ parser.skipOws();
+ if (parser.atEnd()) {
+ break;
+ }
+ if (!parser.consume(',')) {
+ throw new ParseException("Invalid Use-As-Dictionary dictionary separator");
+ }
+ parser.skipOws();
+ if (parser.atEnd()) {
+ throw new ParseException("Trailing comma in Use-As-Dictionary");
+ }
+ }
+
+ if (match == null) {
+ throw new ParseException("Use-As-Dictionary requires a match member");
+ }
+ return new UseAsDictionary(match, matchDest, id, type);
+ }
+
+ /**
+ * @return the URL pattern the dictionary applies to.
+ */
+ String getMatch() {
+ return match;
+ }
+
+ /**
+ * @return the request destinations the offer is restricted to, unmodifiable and empty when
+ * unrestricted.
+ */
+ List getMatchDest() {
+ return matchDest;
+ }
+
+ /**
+ * @return the opaque identifier to echo in {@code Dictionary-ID}, or the empty string when none
+ * was offered.
+ */
+ String getId() {
+ return id;
+ }
+
+ /**
+ * @return the dictionary format token, {@link #RAW} when the {@code type} member was absent.
+ */
+ String getType() {
+ return type;
+ }
+
+ /**
+ * Whether the offered {@code type} is one this client can act on. Only {@link #RAW} is honoured;
+ * any other token leaves the offer parseable but unusable.
+ *
+ * @return {@code true} if the dictionary can be used.
+ */
+ boolean isSupported() {
+ return RAW.equals(type);
+ }
+
+ /**
+ * A parsed Structured Field member value, reduced to only what the dictionary members care
+ * about. Item types with no bearing on {@code Use-As-Dictionary} (numbers, byte sequences,
+ * booleans, dates, display strings) are recognised for validation but collapsed to
+ * {@link #OTHER}; only Strings, Tokens and inner lists carry their value forward. For an inner
+ * list {@link #stringList} records whether every element was a String, which distinguishes a
+ * valid {@code match-dest} from one contaminated by non-String items.
+ */
+ private static final class Value {
+ static final int OTHER = 0;
+ static final int STRING = 1;
+ static final int TOKEN = 2;
+ static final int INNER_LIST = 3;
+
+ final int kind;
+ final String stringValue;
+ final List listValue;
+ final boolean stringList;
+
+ Value(final int kind, final String stringValue, final List listValue, final boolean stringList) {
+ this.kind = kind;
+ this.stringValue = stringValue;
+ this.listValue = listValue;
+ this.stringList = stringList;
+ }
+
+ static Value string(final String value) {
+ return new Value(STRING, value, null, false);
+ }
+
+ static Value token(final String value) {
+ return new Value(TOKEN, value, null, false);
+ }
+
+ static Value other() {
+ return new Value(OTHER, null, null, false);
+ }
+
+ static Value bool() {
+ return other();
+ }
+
+ static Value innerList(final List values, final boolean stringsOnly) {
+ return new Value(INNER_LIST, null,
+ Collections.unmodifiableList(new ArrayList<>(values)), stringsOnly);
+ }
+ }
+
+ /**
+ * A minimal recursive-descent parser for the subset of the HTTP Structured Fields grammar the
+ * {@code Use-As-Dictionary} header exercises: a Dictionary of keys mapping to bare items or
+ * inner lists, with parameters. It advances a cursor over the input and validates each construct
+ * strictly, but only retains the String, Token and inner-list values the caller inspects; every
+ * other item type is parsed for well-formedness and discarded. The parser is single-use and not
+ * thread-safe.
+ */
+ private static final class Parser {
+ private final String value;
+ private int pos;
+
+ Parser(final String value) {
+ this.value = value;
+ }
+
+ boolean atEnd() {
+ return pos >= value.length();
+ }
+
+ /**
+ * Skips optional whitespace, the space and horizontal-tab run allowed around Dictionary
+ * members.
+ */
+ void skipOws() {
+ while (!atEnd()) {
+ final char ch = value.charAt(pos);
+ if (ch == ' ' || ch == '\t') {
+ pos++;
+ } else {
+ break;
+ }
+ }
+ }
+
+ /**
+ * Skips SP characters. RFC 9651 permits OWS around top-level Dictionary
+ * separators, but only SP before the field value and after a parameter
+ * semicolon.
+ */
+ void skipSp() {
+ while (!atEnd() && value.charAt(pos) == ' ') {
+ pos++;
+ }
+ }
+
+ /**
+ * Consumes the next character if it matches, reporting whether it did without advancing on a
+ * mismatch.
+ *
+ * @param expected the character to match.
+ * @return {@code true} if the character was present and consumed.
+ */
+ boolean consume(final char expected) {
+ if (!atEnd() && value.charAt(pos) == expected) {
+ pos++;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Parses a Structured Field key, the lower-case identifier that names a Dictionary member or
+ * a parameter.
+ *
+ * @return the key.
+ * @throws ParseException if no valid key starts at the cursor.
+ */
+ String parseKey() throws ParseException {
+ if (atEnd() || !isKeyStart(value.charAt(pos))) {
+ throw new ParseException("Invalid Structured Field dictionary key");
+ }
+ final int start = pos++;
+ while (!atEnd() && isKeyChar(value.charAt(pos))) {
+ pos++;
+ }
+ return value.substring(start, pos);
+ }
+
+ /**
+ * Parses the value bound to a Dictionary key, which is either an inner list or a bare item
+ * followed by its parameters.
+ *
+ * @return the parsed value.
+ * @throws ParseException if the value is malformed.
+ */
+ Value parseMemberValue() throws ParseException {
+ final Value result;
+ if (!atEnd() && value.charAt(pos) == '(') {
+ result = parseInnerList();
+ } else {
+ result = parseBareItem();
+ parseParameters();
+ }
+ return result;
+ }
+
+ /**
+ * Parses a parenthesised inner list. Only String elements are collected; the presence of any
+ * non-String element clears the strings-only flag so a {@code match-dest} carrying anything
+ * other than Strings can be rejected upstream. Per-item and whole-list parameters are parsed
+ * and discarded.
+ *
+ * @return an {@link Value#INNER_LIST} value.
+ * @throws ParseException if the list is unterminated or an element or separator is invalid.
+ */
+ Value parseInnerList() throws ParseException {
+ pos++;
+ final List strings = new ArrayList<>();
+ boolean stringsOnly = true;
+ while (true) {
+ while (!atEnd() && value.charAt(pos) == ' ') {
+ pos++;
+ }
+ if (atEnd()) {
+ throw new ParseException("Unterminated Structured Field inner list");
+ }
+ if (value.charAt(pos) == ')') {
+ pos++;
+ break;
+ }
+ final Value item = parseBareItem();
+ if (item.kind == Value.STRING) {
+ strings.add(item.stringValue);
+ } else {
+ stringsOnly = false;
+ }
+ parseParameters();
+ if (!atEnd() && value.charAt(pos) != ')' && value.charAt(pos) != ' ') {
+ throw new ParseException("Invalid Structured Field inner list separator");
+ }
+ }
+ parseParameters();
+ return Value.innerList(strings, stringsOnly);
+ }
+
+ /**
+ * Parses a single bare item, dispatching on the leading character across the whole item
+ * grammar: String, Token, Integer or Decimal, Byte Sequence, Boolean, Date and Display
+ * String. Only Strings and Tokens retain their value; the remaining types are validated and
+ * reduced to {@link Value#OTHER}.
+ *
+ * @return the parsed item.
+ * @throws ParseException if no valid item starts at the cursor.
+ */
+ Value parseBareItem() throws ParseException {
+ if (atEnd()) {
+ throw new ParseException("Missing Structured Field item");
+ }
+ final char ch = value.charAt(pos);
+ if (ch == '"') {
+ return Value.string(parseString());
+ }
+ if (isTokenStart(ch)) {
+ return Value.token(parseToken());
+ }
+ if (ch == '-' || isDigit(ch)) {
+ parseNumber();
+ return Value.other();
+ }
+ if (ch == ':') {
+ parseByteSequence();
+ return Value.other();
+ }
+ if (ch == '?') {
+ parseBoolean();
+ return Value.other();
+ }
+ if (ch == '@') {
+ pos++;
+ parseInteger();
+ return Value.other();
+ }
+ if (ch == '%') {
+ parseDisplayString();
+ return Value.other();
+ }
+ throw new ParseException("Invalid Structured Field bare item");
+ }
+
+ /**
+ * Parses a quoted String, unescaping the only two permitted escapes, backslash and double
+ * quote, and rejecting any character outside printable ASCII.
+ *
+ * @return the unescaped String content.
+ * @throws ParseException if an escape or character is invalid or the String is unterminated.
+ */
+ String parseString() throws ParseException {
+ pos++;
+ final StringBuilder result = new StringBuilder();
+ while (!atEnd()) {
+ final char ch = value.charAt(pos++);
+ if (ch == '"') {
+ return result.toString();
+ }
+ if (ch == '\\') {
+ if (atEnd()) {
+ throw new ParseException("Invalid Structured Field String escape");
+ }
+ final char escaped = value.charAt(pos++);
+ if (escaped != '\\' && escaped != '"') {
+ throw new ParseException("Invalid Structured Field String escape");
+ }
+ result.append(escaped);
+ } else {
+ if (ch < 0x20 || ch > 0x7e) {
+ throw new ParseException("Invalid Structured Field String character");
+ }
+ result.append(ch);
+ }
+ }
+ throw new ParseException("Unterminated Structured Field String");
+ }
+
+ /**
+ * Parses a Token, the unquoted identifier used by the {@code type} member.
+ *
+ * @return the token text.
+ * @throws ParseException if no token character follows the start.
+ */
+ String parseToken() throws ParseException {
+ final int start = pos++;
+ while (!atEnd() && isTokenChar(value.charAt(pos))) {
+ pos++;
+ }
+ if (pos == start) {
+ throw new ParseException("Invalid Structured Field Token");
+ }
+ return value.substring(start, pos);
+ }
+
+ /**
+ * Parses an Integer or Decimal, consuming an optional sign, the integer digits and, for a
+ * Decimal, a fractional part of at most three digits. The value is validated but not
+ * retained.
+ *
+ * @throws ParseException if no digits are present or the decimal form is malformed.
+ */
+ void parseNumber() throws ParseException {
+ if (consume('-') && atEnd()) {
+ throw new ParseException("Invalid Structured Field number");
+ }
+ final int start = pos;
+ while (!atEnd() && isDigit(value.charAt(pos))) {
+ pos++;
+ }
+ if (pos == start) {
+ throw new ParseException("Invalid Structured Field number");
+ }
+ final int integerDigits = pos - start;
+ if (consume('.')) {
+ if (integerDigits > 12) {
+ throw new ParseException("Structured Field decimal is out of range");
+ }
+ final int fraction = pos;
+ while (!atEnd() && isDigit(value.charAt(pos)) && pos - fraction < 3) {
+ pos++;
+ }
+ if (pos == fraction || !atEnd() && isDigit(value.charAt(pos))) {
+ throw new ParseException("Invalid Structured Field decimal");
+ }
+ } else if (integerDigits > 15) {
+ throw new ParseException("Structured Field integer is out of range");
+ }
+ }
+
+ /**
+ * Parses the integer that follows the {@code @} marker of a Date item. The value is
+ * validated but not retained.
+ *
+ * @throws ParseException if no digits are present.
+ */
+ void parseInteger() throws ParseException {
+ if (consume('-') && atEnd()) {
+ throw new ParseException("Invalid Structured Field integer");
+ }
+ final int start = pos;
+ while (!atEnd() && isDigit(value.charAt(pos))) {
+ pos++;
+ }
+ if (pos == start || pos - start > 15) {
+ throw new ParseException("Invalid Structured Field integer");
+ }
+ }
+
+ /**
+ * Parses a colon-delimited Byte Sequence, verifying that the enclosed text is well-formed
+ * Base64. The decoded bytes are discarded.
+ *
+ * @throws ParseException if the encoding is invalid or the sequence is unterminated.
+ */
+ void parseByteSequence() throws ParseException {
+ pos++;
+ final int start = pos;
+ while (!atEnd() && value.charAt(pos) != ':') {
+ final char ch = value.charAt(pos);
+ if (!(isAlpha(ch) || isDigit(ch) || ch == '+' || ch == '/' || ch == '=')) {
+ throw new ParseException("Invalid Structured Field Byte Sequence");
+ }
+ pos++;
+ }
+ if (atEnd()) {
+ throw new ParseException("Unterminated Structured Field Byte Sequence");
+ }
+ final String encoded = value.substring(start, pos++);
+ try {
+ Base64.getDecoder().decode(encoded);
+ } catch (final IllegalArgumentException ex) {
+ throw new ParseException("Invalid Structured Field Byte Sequence");
+ }
+ }
+
+ /**
+ * Parses a Boolean, the {@code ?0} or {@code ?1} form. The value is validated but not
+ * retained.
+ *
+ * @throws ParseException if the character after {@code ?} is neither {@code 0} nor {@code 1}.
+ */
+ void parseBoolean() throws ParseException {
+ pos++;
+ if (atEnd() || value.charAt(pos) != '0' && value.charAt(pos) != '1') {
+ throw new ParseException("Invalid Structured Field Boolean");
+ }
+ pos++;
+ }
+
+ /**
+ * Parses a Display String, the {@code %"..."} form whose content is UTF-8 percent-encoded.
+ * Percent escapes must be two lower-case hex digits; the decoded text is discarded.
+ *
+ * @throws ParseException if the opening quote, an escape or a character is invalid, or the
+ * string is unterminated.
+ */
+ void parseDisplayString() throws ParseException {
+ pos++;
+ if (!consume('"')) {
+ throw new ParseException("Invalid Structured Field Display String");
+ }
+ final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ while (!atEnd()) {
+ final char ch = value.charAt(pos++);
+ if (ch == '"') {
+ try {
+ StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes.toByteArray()));
+ return;
+ } catch (final CharacterCodingException ex) {
+ throw new ParseException("Invalid UTF-8 in Structured Field Display String");
+ }
+ }
+ if (ch == '%') {
+ if (pos + 1 >= value.length()
+ || !isLowerHex(value.charAt(pos))
+ || !isLowerHex(value.charAt(pos + 1))) {
+ throw new ParseException("Invalid Structured Field Display String escape");
+ }
+ bytes.write(hexValue(value.charAt(pos)) * 16 + hexValue(value.charAt(pos + 1)));
+ pos += 2;
+ } else if (ch < 0x20 || ch > 0x7e) {
+ throw new ParseException("Invalid Structured Field Display String character");
+ } else {
+ bytes.write((byte) ch);
+ }
+ }
+ throw new ParseException("Unterminated Structured Field Display String");
+ }
+
+ /**
+ * Consumes any trailing {@code ;key=value} parameters attached to an item or list. This
+ * implementation assigns no meaning to parameters, so they are validated and discarded.
+ *
+ * @throws ParseException if a parameter key or value is malformed.
+ */
+ void parseParameters() throws ParseException {
+ while (!atEnd() && value.charAt(pos) == ';') {
+ pos++;
+ skipSp();
+ final String ignored = parseKey();
+ if (ignored.length() == 0) {
+ throw new ParseException("Invalid Structured Field parameter");
+ }
+ if (consume('=')) {
+ parseBareItem();
+ }
+ }
+ }
+
+ private static boolean isKeyStart(final char ch) {
+ return ch >= 'a' && ch <= 'z' || ch == '*';
+ }
+
+ private static boolean isKeyChar(final char ch) {
+ return isKeyStart(ch) || isDigit(ch) || ch == '_' || ch == '-' || ch == '.';
+ }
+
+ private static boolean isTokenStart(final char ch) {
+ return isAlpha(ch) || ch == '*';
+ }
+
+ private static boolean isTokenChar(final char ch) {
+ return isAlpha(ch) || isDigit(ch)
+ || "!#$%&'*+-.^_`|~:/".indexOf(ch) >= 0;
+ }
+
+ private static boolean isAlpha(final char ch) {
+ return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z';
+ }
+
+ private static boolean isDigit(final char ch) {
+ return ch >= '0' && ch <= '9';
+ }
+
+ private static boolean isLowerHex(final char ch) {
+ return isDigit(ch) || ch >= 'a' && ch <= 'f';
+ }
+
+ private static int hexValue(final char ch) {
+ return isDigit(ch) ? ch - '0' : ch - 'a' + 10;
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/async/methods/TestInflatingDictionaryBrotliDataConsumer.java b/httpclient5/src/test/java/org/apache/hc/client5/http/async/methods/TestInflatingDictionaryBrotliDataConsumer.java
new file mode 100644
index 0000000000..673420b974
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/async/methods/TestInflatingDictionaryBrotliDataConsumer.java
@@ -0,0 +1,341 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.async.methods;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+
+import com.aayushatharva.brotli4j.Brotli4jLoader;
+import com.aayushatharva.brotli4j.encoder.BrotliOutputStream;
+import com.aayushatharva.brotli4j.encoder.Encoder;
+import com.aayushatharva.brotli4j.encoder.PreparedDictionary;
+
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link InflatingDictionaryBrotliDataConsumer}.
+ */
+class TestInflatingDictionaryBrotliDataConsumer {
+
+ private static final byte[] MAGIC = {
+ (byte) 0xff, 0x44, 0x43, 0x42
+ };
+
+ private static final int HASH_LENGTH = 32;
+ private static final int HEADER_LENGTH = MAGIC.length + HASH_LENGTH;
+
+ private static CompressionDictionary dictionary(final byte[] content) {
+ final Instant storedAt = Instant.parse("2020-01-01T00:00:00Z");
+ final Instant validUntil = Instant.parse("2030-01-01T00:00:00Z");
+ return new CompressionDictionary(
+ content,
+ URI.create("https://example.com/dict"),
+ "/*",
+ "dict-1",
+ storedAt,
+ validUntil);
+ }
+
+ private static byte[] header(final byte[] magic, final byte[] hash) {
+ final byte[] out = new byte[HEADER_LENGTH];
+ System.arraycopy(magic, 0, out, 0, magic.length);
+ System.arraycopy(hash, 0, out, magic.length, HASH_LENGTH);
+ return out;
+ }
+
+ @Test
+ void invalidMagicThrowsFromConsumeSingleBuffer() {
+ final AsyncDataConsumer downstream = mock(AsyncDataConsumer.class);
+ final CompressionDictionary dictionary = dictionary(new byte[]{1});
+ final InflatingDictionaryBrotliDataConsumer consumer =
+ new InflatingDictionaryBrotliDataConsumer(downstream, dictionary);
+
+ final byte[] bogusMagic = {0x00, 0x44, 0x43, 0x42};
+ final byte[] hash = new byte[HASH_LENGTH];
+ final ByteBuffer src = ByteBuffer.wrap(header(bogusMagic, hash));
+
+ final IOException ex = assertThrows(IOException.class, () -> consumer.consume(src));
+ assertTrue(ex.getMessage().contains("Invalid DCB stream header"), ex.getMessage());
+ verifyNoInteractions(downstream);
+ }
+
+ @Test
+ void invalidMagicThrowsWhenHeaderSplitAcrossTwoBuffers() {
+ final AsyncDataConsumer downstream = mock(AsyncDataConsumer.class);
+ final CompressionDictionary dictionary = dictionary(new byte[]{1});
+ final InflatingDictionaryBrotliDataConsumer consumer =
+ new InflatingDictionaryBrotliDataConsumer(downstream, dictionary);
+
+ final byte[] bogusMagic = {0x00, 0x44, 0x43, 0x42};
+ final byte[] hash = new byte[HASH_LENGTH];
+ final byte[] full = header(bogusMagic, hash);
+
+ // First buffer only carries a partial header; must not throw yet.
+ final ByteBuffer first = ByteBuffer.wrap(full, 0, 2).slice();
+ assertDoesNotThrowConsume(consumer, first);
+
+ // Completing the header triggers validation.
+ final ByteBuffer second = ByteBuffer.wrap(full, 2, full.length - 2).slice();
+ final IOException ex = assertThrows(IOException.class, () -> consumer.consume(second));
+ assertTrue(ex.getMessage().contains("Invalid DCB stream header"), ex.getMessage());
+ verifyNoInteractions(downstream);
+ }
+
+ @Test
+ void differentDictionaryHashThrowsFromConsume() {
+ final AsyncDataConsumer downstream = mock(AsyncDataConsumer.class);
+ final CompressionDictionary dictionary = dictionary(new byte[]{1});
+ final InflatingDictionaryBrotliDataConsumer consumer =
+ new InflatingDictionaryBrotliDataConsumer(downstream, dictionary);
+
+ final byte[] hash = new byte[HASH_LENGTH];
+ for (int i = 0; i < HASH_LENGTH; i++) {
+ hash[i] = (byte) i;
+ }
+ final ByteBuffer src = ByteBuffer.wrap(header(MAGIC, hash));
+
+ final IOException ex = assertThrows(IOException.class, () -> consumer.consume(src));
+ assertTrue(ex.getMessage().contains("does not use the negotiated dictionary"), ex.getMessage());
+ verifyNoInteractions(downstream);
+ }
+
+ @Test
+ void differentDictionaryHashThrowsWhenHeaderSplitAcrossTwoBuffers() {
+ final AsyncDataConsumer downstream = mock(AsyncDataConsumer.class);
+ final CompressionDictionary dictionary = dictionary(new byte[]{1});
+ final InflatingDictionaryBrotliDataConsumer consumer =
+ new InflatingDictionaryBrotliDataConsumer(downstream, dictionary);
+
+ final byte[] hash = new byte[HASH_LENGTH];
+ final byte[] full = header(MAGIC, hash);
+
+ final ByteBuffer first = ByteBuffer.wrap(full, 0, 20).slice();
+ assertDoesNotThrowConsume(consumer, first);
+
+ final ByteBuffer second = ByteBuffer.wrap(full, 20, full.length - 20).slice();
+ final IOException ex = assertThrows(IOException.class, () -> consumer.consume(second));
+ assertTrue(ex.getMessage().contains("does not use the negotiated dictionary"), ex.getMessage());
+ verifyNoInteractions(downstream);
+ }
+
+ @Test
+ void knownHashPresentAndMatchingReachesDecoderInit() {
+ final AsyncDataConsumer downstream = mock(AsyncDataConsumer.class);
+ final CompressionDictionary dict = dictionary(new byte[]{'h', 'e', 'l', 'l', 'o'});
+
+ final InflatingDictionaryBrotliDataConsumer consumer =
+ new InflatingDictionaryBrotliDataConsumer(downstream, dict);
+
+ final ByteBuffer src = ByteBuffer.wrap(header(MAGIC, dict.getSha256()));
+
+ try {
+ consumer.consume(src);
+ // If the native library is present, header consumed without error.
+ } catch (final IOException ex) {
+ assertTrue(!ex.getMessage().contains("does not use the negotiated dictionary"), ex.getMessage());
+ } catch (final Throwable linkError) {
+ // UnsatisfiedLinkError / NoClassDefFoundError when native lib absent.
+ assertTrue(true);
+ }
+ }
+
+ @Test
+ void hashPresentButNotMatchingThrows() {
+ final AsyncDataConsumer downstream = mock(AsyncDataConsumer.class);
+ final CompressionDictionary dict = dictionary(new byte[]{1, 2, 3, 4});
+
+ final InflatingDictionaryBrotliDataConsumer consumer =
+ new InflatingDictionaryBrotliDataConsumer(downstream, dict);
+
+ // Header hash of all-zeros will not equal the dictionary's real SHA-256.
+ final byte[] hash = new byte[HASH_LENGTH];
+ final ByteBuffer src = ByteBuffer.wrap(header(MAGIC, hash));
+
+ final IOException ex = assertThrows(IOException.class, () -> consumer.consume(src));
+ assertTrue(ex.getMessage().contains("does not use the negotiated dictionary"), ex.getMessage());
+ verifyNoInteractions(downstream);
+ }
+
+ @Test
+ void truncatedHeaderThenStreamEndThrows() {
+ final AsyncDataConsumer downstream = mock(AsyncDataConsumer.class);
+ final CompressionDictionary dictionary = dictionary(new byte[]{1});
+ final InflatingDictionaryBrotliDataConsumer consumer =
+ new InflatingDictionaryBrotliDataConsumer(downstream, dictionary);
+
+ // Fewer than 36 bytes: header stays incomplete, decoder stays null.
+ final byte[] partial = new byte[10];
+ System.arraycopy(MAGIC, 0, partial, 0, MAGIC.length);
+ final ByteBuffer src = ByteBuffer.wrap(partial);
+ assertDoesNotThrowConsume(consumer, src);
+
+ final IOException ex = assertThrows(IOException.class,
+ () -> consumer.streamEnd(Collections.emptyList()));
+ assertTrue(ex.getMessage().contains("Truncated DCB stream header"), ex.getMessage());
+ verifyNoInteractions(downstream);
+ }
+
+ @Test
+ void streamEndWithNoDataThrowsTruncatedHeader() {
+ final AsyncDataConsumer downstream = mock(AsyncDataConsumer.class);
+ final CompressionDictionary dictionary = dictionary(new byte[]{1});
+ final InflatingDictionaryBrotliDataConsumer consumer =
+ new InflatingDictionaryBrotliDataConsumer(downstream, dictionary);
+
+ final IOException ex = assertThrows(IOException.class,
+ () -> consumer.streamEnd(Collections.emptyList()));
+ assertTrue(ex.getMessage().contains("Truncated DCB stream header"), ex.getMessage());
+ verifyNoInteractions(downstream);
+ }
+
+ @Test
+ void roundTripWithSharedDictionary() throws Exception {
+ Assumptions.assumeTrue(brotliAvailable(), "Brotli native runtime is unavailable");
+
+ final byte[] dictionaryContent =
+ "the quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.UTF_8);
+ final byte[] payload = ("the quick brown fox jumps over the lazy dog "
+ + "and then the quick brown fox runs away").getBytes(StandardCharsets.UTF_8);
+ final CompressionDictionary dictionary = dictionary(dictionaryContent);
+ final byte[] stream = concat(MAGIC, dictionary.getSha256(), compress(dictionaryContent, payload));
+ final AccumulatingConsumer downstream = new AccumulatingConsumer();
+ final InflatingDictionaryBrotliDataConsumer consumer =
+ new InflatingDictionaryBrotliDataConsumer(downstream, dictionary);
+
+ for (int i = 0; i < stream.length; i++) {
+ consumer.consume(ByteBuffer.wrap(stream, i, 1));
+ }
+ consumer.streamEnd(Collections.emptyList());
+
+ assertArrayEquals(payload, downstream.toByteArray());
+ assertTrue(downstream.ended);
+ consumer.releaseResources();
+ }
+
+ private static byte[] compress(final byte[] dictionary, final byte[] payload) throws IOException {
+ final ByteBuffer buffer = ByteBuffer.allocateDirect(dictionary.length);
+ buffer.put(dictionary).flip();
+ final PreparedDictionary prepared = Encoder.prepareDictionary(buffer, 0);
+ final ByteArrayOutputStream compressed = new ByteArrayOutputStream();
+ try {
+ final Encoder.Parameters parameters = Encoder.Parameters.create(6, 24, Encoder.Mode.TEXT);
+ try (BrotliOutputStream out = new BrotliOutputStream(compressed, parameters)) {
+ out.attachDictionary(prepared);
+ out.write(payload);
+ }
+ return compressed.toByteArray();
+ } finally {
+ if (prepared instanceof AutoCloseable) {
+ try {
+ ((AutoCloseable) prepared).close();
+ } catch (final Exception ex) {
+ throw new IOException("Unable to release Brotli dictionary", ex);
+ }
+ }
+ }
+ }
+
+ private static byte[] concat(final byte[]... parts) {
+ int length = 0;
+ for (final byte[] part : parts) {
+ length += part.length;
+ }
+ final byte[] result = new byte[length];
+ int offset = 0;
+ for (final byte[] part : parts) {
+ System.arraycopy(part, 0, result, offset, part.length);
+ offset += part.length;
+ }
+ return result;
+ }
+
+ private static boolean brotliAvailable() {
+ try {
+ Brotli4jLoader.ensureAvailability();
+ return true;
+ } catch (final Throwable ex) {
+ return false;
+ }
+ }
+
+ private static final class AccumulatingConsumer implements AsyncDataConsumer {
+ private final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ private boolean ended;
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ capacityChannel.update(Integer.MAX_VALUE);
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) {
+ while (src.hasRemaining()) {
+ out.write(src.get());
+ }
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ ended = true;
+ }
+
+ @Override
+ public void releaseResources() {
+ }
+
+ byte[] toByteArray() {
+ return out.toByteArray();
+ }
+ }
+
+ private static void assertDoesNotThrowConsume(
+ final InflatingDictionaryBrotliDataConsumer consumer, final ByteBuffer src) {
+ try {
+ consumer.consume(src);
+ } catch (final IOException ex) {
+ throw new AssertionError("Unexpected IOException on partial header", ex);
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/async/methods/TestInflatingDictionaryZstdDataConsumer.java b/httpclient5/src/test/java/org/apache/hc/client5/http/async/methods/TestInflatingDictionaryZstdDataConsumer.java
new file mode 100644
index 0000000000..defdc9e738
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/async/methods/TestInflatingDictionaryZstdDataConsumer.java
@@ -0,0 +1,270 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.async.methods;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+
+import com.github.luben.zstd.ZstdCompressCtx;
+
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.client5.http.impl.ZstdRuntime;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+
+class TestInflatingDictionaryZstdDataConsumer {
+
+ private static final byte[] MAGIC = {
+ 0x5e, 0x2a, 0x4d, 0x18, 0x20, 0x00, 0x00, 0x00
+ };
+
+ private static final int HASH_LENGTH = 32;
+
+ /**
+ * Fake downstream consumer that accumulates all consumed bytes.
+ */
+ private static final class AccumulatingConsumer implements AsyncDataConsumer {
+
+ private final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ private boolean ended;
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ capacityChannel.update(Integer.MAX_VALUE);
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ while (src.hasRemaining()) {
+ out.write(src.get());
+ }
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ ended = true;
+ }
+
+ @Override
+ public void releaseResources() {
+ }
+
+ byte[] toByteArray() {
+ return out.toByteArray();
+ }
+
+ boolean isEnded() {
+ return ended;
+ }
+ }
+
+ private static CompressionDictionary dictionaryOf(final byte[] content) {
+ return new CompressionDictionary(
+ content,
+ URI.create("https://example.com/"),
+ "/*",
+ "dict-1",
+ Instant.parse("2026-01-01T00:00:00Z"),
+ Instant.parse("2099-01-01T00:00:00Z"));
+ }
+
+ private static byte[] concat(final byte[]... parts) {
+ int total = 0;
+ for (final byte[] part : parts) {
+ total += part.length;
+ }
+ final byte[] result = new byte[total];
+ int offset = 0;
+ for (final byte[] part : parts) {
+ System.arraycopy(part, 0, result, offset, part.length);
+ offset += part.length;
+ }
+ return result;
+ }
+
+ private static byte[] compress(final byte[] dictionaryContent, final byte[] payload) {
+ try (ZstdCompressCtx cctx = new ZstdCompressCtx()) {
+ cctx.loadDict(dictionaryContent);
+ return cctx.compress(payload);
+ }
+ }
+
+ @Test
+ void invalidMagicRaisesIOException() {
+ Assumptions.assumeTrue(ZstdRuntime.available());
+
+ final AccumulatingConsumer downstream = new AccumulatingConsumer();
+ final CompressionDictionary dictionary = dictionaryOf(new byte[]{1});
+ final InflatingDictionaryZstdDataConsumer consumer =
+ new InflatingDictionaryZstdDataConsumer(downstream, dictionary);
+
+ final byte[] header = new byte[MAGIC.length + HASH_LENGTH];
+ // deliberately wrong first byte
+ header[0] = 0x00;
+
+ final ByteBuffer src = ByteBuffer.wrap(header);
+ final IOException ex = Assertions.assertThrows(IOException.class, () -> consumer.consume(src));
+ Assertions.assertTrue(ex.getMessage().contains("Invalid DCZ stream header"), ex.getMessage());
+
+ consumer.releaseResources();
+ }
+
+ @Test
+ void differentDictionaryHashRaisesIOException() {
+ Assumptions.assumeTrue(ZstdRuntime.available());
+
+ final AccumulatingConsumer downstream = new AccumulatingConsumer();
+ final CompressionDictionary dictionary = dictionaryOf(new byte[]{1});
+ final InflatingDictionaryZstdDataConsumer consumer =
+ new InflatingDictionaryZstdDataConsumer(downstream, dictionary);
+
+ final byte[] hash = new byte[HASH_LENGTH];
+ for (int i = 0; i < hash.length; i++) {
+ hash[i] = (byte) i;
+ }
+ final byte[] header = concat(MAGIC, hash);
+
+ final ByteBuffer src = ByteBuffer.wrap(header);
+ final IOException ex = Assertions.assertThrows(IOException.class, () -> consumer.consume(src));
+ Assertions.assertEquals("DCZ stream does not use the negotiated dictionary", ex.getMessage());
+
+ consumer.releaseResources();
+ }
+
+ @Test
+ void truncatedHeaderThenStreamEndRaisesIOException() {
+ Assumptions.assumeTrue(ZstdRuntime.available());
+
+ final AccumulatingConsumer downstream = new AccumulatingConsumer();
+ final CompressionDictionary dictionary = dictionaryOf(new byte[]{1});
+ final InflatingDictionaryZstdDataConsumer consumer =
+ new InflatingDictionaryZstdDataConsumer(downstream, dictionary);
+
+ // feed only part of the magic; header stays incomplete, so not initialized
+ final ByteBuffer src = ByteBuffer.wrap(new byte[] {MAGIC[0], MAGIC[1], MAGIC[2]});
+ Assertions.assertDoesNotThrow(() -> consumer.consume(src));
+
+ final IOException ex = Assertions.assertThrows(IOException.class,
+ () -> consumer.streamEnd(Collections.emptyList()));
+ Assertions.assertEquals("Truncated DCZ stream header", ex.getMessage());
+
+ consumer.releaseResources();
+ }
+
+ @Test
+ void headerSplitAcrossBuffersRejectsDifferentDictionary() {
+ Assumptions.assumeTrue(ZstdRuntime.available());
+
+ final AccumulatingConsumer downstream = new AccumulatingConsumer();
+ final CompressionDictionary dictionary = dictionaryOf(new byte[]{1});
+ final InflatingDictionaryZstdDataConsumer consumer =
+ new InflatingDictionaryZstdDataConsumer(downstream, dictionary);
+
+ final byte[] hash = new byte[HASH_LENGTH];
+ final byte[] header = concat(MAGIC, hash);
+
+ // split header across two ByteBuffers; the valid MAGIC still validates,
+ // then the all-zero hash is rejected because it is not the negotiated dictionary.
+ final ByteBuffer first = ByteBuffer.wrap(header, 0, 5);
+ final ByteBuffer second = ByteBuffer.wrap(header, 5, header.length - 5);
+
+ Assertions.assertDoesNotThrow(() -> consumer.consume(first));
+ final IOException ex = Assertions.assertThrows(IOException.class, () -> consumer.consume(second));
+ Assertions.assertEquals("DCZ stream does not use the negotiated dictionary", ex.getMessage());
+
+ consumer.releaseResources();
+ }
+
+ @Test
+ void roundTripSingleBuffer() throws Exception {
+ Assumptions.assumeTrue(ZstdRuntime.available());
+
+ final byte[] dictionaryContent = "the quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.UTF_8);
+ final byte[] payload = ("the quick brown fox jumps over the lazy dog "
+ + "and then the quick brown fox runs away").getBytes(StandardCharsets.UTF_8);
+
+ final CompressionDictionary dictionary = dictionaryOf(dictionaryContent);
+
+ final byte[] frame = compress(dictionaryContent, payload);
+ final byte[] stream = concat(MAGIC, dictionary.getSha256(), frame);
+
+ final AccumulatingConsumer downstream = new AccumulatingConsumer();
+ final InflatingDictionaryZstdDataConsumer consumer =
+ new InflatingDictionaryZstdDataConsumer(downstream, dictionary);
+
+ consumer.consume(ByteBuffer.wrap(stream));
+ consumer.streamEnd(Collections.emptyList());
+
+ Assertions.assertArrayEquals(payload, downstream.toByteArray());
+ Assertions.assertTrue(downstream.isEnded());
+
+ consumer.releaseResources();
+ }
+
+ @Test
+ void roundTripSplitAcrossManyBuffers() throws Exception {
+ Assumptions.assumeTrue(ZstdRuntime.available());
+
+ final byte[] dictionaryContent = "dictionary payload sample content".getBytes(StandardCharsets.UTF_8);
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 200; i++) {
+ sb.append("dictionary payload sample content line ").append(i).append('\n');
+ }
+ final byte[] payload = sb.toString().getBytes(StandardCharsets.UTF_8);
+
+ final CompressionDictionary dictionary = dictionaryOf(dictionaryContent);
+
+ final byte[] frame = compress(dictionaryContent, payload);
+ final byte[] stream = concat(MAGIC, dictionary.getSha256(), frame);
+
+ final AccumulatingConsumer downstream = new AccumulatingConsumer();
+ final InflatingDictionaryZstdDataConsumer consumer =
+ new InflatingDictionaryZstdDataConsumer(downstream, dictionary);
+
+ // feed one byte at a time to exercise header/frame splitting
+ for (int i = 0; i < stream.length; i++) {
+ consumer.consume(ByteBuffer.wrap(stream, i, 1));
+ }
+ consumer.streamEnd(Collections.emptyList());
+
+ Assertions.assertArrayEquals(payload, downstream.toByteArray());
+ Assertions.assertTrue(downstream.isEnded());
+
+ consumer.releaseResources();
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/compress/TestBasicCompressionDictionaryStore.java b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/compress/TestBasicCompressionDictionaryStore.java
new file mode 100644
index 0000000000..228e446261
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/compress/TestBasicCompressionDictionaryStore.java
@@ -0,0 +1,259 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.entity.compress;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hc.client5.http.cookie.BasicCookieStore;
+import org.apache.hc.client5.http.cookie.CookieStore;
+import org.junit.jupiter.api.Test;
+
+class TestBasicCompressionDictionaryStore {
+
+ private static final Instant STORED_AT = Instant.parse("2020-01-01T00:00:00Z");
+ private static final Instant VALID_UNTIL = Instant.parse("2030-01-01T00:00:00Z");
+ private final CookieStore partition = new BasicCookieStore();
+
+ private static CompressionDictionary dictionary(final byte[] content, final URI source) {
+ return new CompressionDictionary(content, source, "/path", "id", STORED_AT, VALID_UNTIL);
+ }
+
+ private static CompressionDictionary dictionary(final int n) {
+ return dictionary(new byte[]{(byte) n}, URI.create("https://example.com/"));
+ }
+
+ @Test
+ void addNullThrows() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ assertThrows(NullPointerException.class, () -> store.add(null, dictionary(1)));
+ assertThrows(NullPointerException.class, () -> store.add(partition, null));
+ }
+
+ @Test
+ void getByHashNullThrows() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ assertThrows(NullPointerException.class,
+ () -> store.getByHash(null, URI.create("https://example.com/"), new byte[32]));
+ assertThrows(NullPointerException.class,
+ () -> store.getByHash(partition, null, new byte[32]));
+ assertThrows(NullPointerException.class,
+ () -> store.getByHash(partition, URI.create("https://example.com/"), null));
+ }
+
+ @Test
+ void getByOriginNullThrows() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ assertThrows(NullPointerException.class,
+ () -> store.getByOrigin(null, URI.create("https://example.com/")));
+ assertThrows(NullPointerException.class, () -> store.getByOrigin(partition, null));
+ }
+
+ @Test
+ void nonPositiveMaxEntriesThrows() {
+ assertThrows(IllegalArgumentException.class, () -> new BasicCompressionDictionaryStore(0));
+ assertThrows(IllegalArgumentException.class, () -> new BasicCompressionDictionaryStore(-1));
+ }
+
+ @Test
+ void defaultConstructorWorks() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ final CompressionDictionary dict = dictionary(1);
+ store.add(partition, dict);
+ assertSame(dict, store.getByHash(partition, URI.create("https://example.com/"), dict.getSha256()));
+ }
+
+ @Test
+ void addThenGetByHashReturnsDictionary() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ final CompressionDictionary dict = dictionary(42);
+ store.add(partition, dict);
+ assertSame(dict, store.getByHash(partition, URI.create("https://example.com/"), dict.getSha256()));
+ }
+
+ @Test
+ void getByHashUnknownReturnsNull() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ store.add(partition, dictionary(1));
+ final CompressionDictionary other = dictionary(99);
+ assertNull(store.getByHash(partition, URI.create("https://example.com/"), other.getSha256()));
+ }
+
+ @Test
+ void sameHashTwiceKeepsSingleEntry() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ final URI source = URI.create("https://example.com/");
+ final CompressionDictionary first = dictionary(new byte[]{7}, source);
+ final CompressionDictionary second = dictionary(new byte[]{7}, source);
+ store.add(partition, first);
+ store.add(partition, second);
+ assertSame(second, store.getByHash(partition, URI.create("https://example.com/"), first.getSha256()));
+ assertEquals(1, store.getByOrigin(partition, source).size());
+ }
+
+ @Test
+ void evictionRemovesOldest() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore(2);
+ final CompressionDictionary first = dictionary(1);
+ final CompressionDictionary second = dictionary(2);
+ final CompressionDictionary third = dictionary(3);
+ store.add(partition, first);
+ store.add(partition, second);
+ store.add(partition, third);
+ assertNull(store.getByHash(partition, URI.create("https://example.com/"), first.getSha256()));
+ assertSame(second, store.getByHash(partition, URI.create("https://example.com/"), second.getSha256()));
+ assertSame(third, store.getByHash(partition, URI.create("https://example.com/"), third.getSha256()));
+ }
+
+ @Test
+ void getByOriginFiltersByHost() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ final CompressionDictionary a = dictionary(new byte[]{1}, URI.create("https://a.example.com/"));
+ final CompressionDictionary b = dictionary(new byte[]{2}, URI.create("https://b.example.com/"));
+ store.add(partition, a);
+ store.add(partition, b);
+ final List result = store.getByOrigin(partition, URI.create("https://b.example.com/"));
+ assertEquals(1, result.size());
+ assertSame(b, result.get(0));
+ }
+
+ @Test
+ void getByOriginTreatsHttpsDefaultPortAsEqual() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ final CompressionDictionary implicit = dictionary(new byte[]{1}, URI.create("https://example.com/"));
+ store.add(partition, implicit);
+ final List result = store.getByOrigin(partition, URI.create("https://example.com:443/"));
+ assertEquals(1, result.size());
+ assertSame(implicit, result.get(0));
+ }
+
+ @Test
+ void getByOriginDistinguishesNonDefaultPort() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ final CompressionDictionary def = dictionary(new byte[]{1}, URI.create("https://example.com/"));
+ final CompressionDictionary alt = dictionary(new byte[]{2}, URI.create("https://example.com:8443/"));
+ store.add(partition, def);
+ store.add(partition, alt);
+ assertEquals(1, store.getByOrigin(partition, URI.create("https://example.com:8443/")).size());
+ assertSame(alt, store.getByOrigin(partition, URI.create("https://example.com:8443/")).get(0));
+ assertSame(def, store.getByOrigin(partition, URI.create("https://example.com/")).get(0));
+ }
+
+ @Test
+ void getByOriginNoMatchReturnsEmpty() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ store.add(partition, dictionary(new byte[]{1}, URI.create("https://example.com/")));
+ final List result =
+ store.getByOrigin(partition, URI.create("https://other.example.com/"));
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void clearEmptiesStore() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ final CompressionDictionary dict = dictionary(1);
+ store.add(partition, dict);
+ store.clear();
+ assertNull(store.getByHash(partition, URI.create("https://example.com/"), dict.getSha256()));
+ assertTrue(store.getByOrigin(partition, URI.create("https://example.com/")).isEmpty());
+ }
+
+ @Test
+ void partitionsAreIsolatedAndCanBeClearedIndependently() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ final CookieStore otherPartition = new BasicCookieStore();
+ final CompressionDictionary first = dictionary(1);
+ final CompressionDictionary second = dictionary(2);
+ store.add(partition, first);
+ store.add(otherPartition, second);
+
+ assertSame(first, store.getByHash(partition, first.getSource(), first.getSha256()));
+ assertNull(store.getByHash(otherPartition, first.getSource(), first.getSha256()));
+ assertSame(second, store.getByHash(otherPartition, second.getSource(), second.getSha256()));
+
+ store.clear(partition);
+ assertTrue(store.getByOrigin(partition, first.getSource()).isEmpty());
+ assertSame(second, store.getByHash(otherPartition, second.getSource(), second.getSha256()));
+ }
+
+ @Test
+ void concurrentAddsStayWithinMaxEntries() throws InterruptedException {
+ final int maxEntries = 16;
+ final int threads = 32;
+ final int perThread = 50;
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore(maxEntries);
+ final ExecutorService executor = Executors.newFixedThreadPool(threads);
+ final CountDownLatch start = new CountDownLatch(1);
+ final CountDownLatch done = new CountDownLatch(threads);
+ final AtomicReference failure = new AtomicReference<>();
+ try {
+ for (int t = 0; t < threads; t++) {
+ final int base = t;
+ executor.execute(() -> {
+ try {
+ start.await();
+ for (int i = 0; i < perThread; i++) {
+ final byte[] content = new byte[]{(byte) base, (byte) i};
+ store.add(partition, dictionary(content, URI.create("https://example.com/")));
+ }
+ } catch (final Throwable ex) {
+ failure.compareAndSet(null, ex);
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+ start.countDown();
+ assertTrue(done.await(30, TimeUnit.SECONDS), "threads did not finish in time");
+ } finally {
+ executor.shutdownNow();
+ }
+ assertNull(failure.get(), "concurrent add threw: " + failure.get());
+ assertTrue(store.getByOrigin(partition, URI.create("https://example.com/")).size() <= maxEntries);
+ }
+
+ @Test
+ void getByHashReturnsNonNullAfterAdd() {
+ final BasicCompressionDictionaryStore store = new BasicCompressionDictionaryStore();
+ final CompressionDictionary dict = dictionary(5);
+ store.add(partition, dict);
+ assertNotNull(store.getByHash(partition, URI.create("https://example.com/"), dict.getSha256()));
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/compress/TestCompressionDictionary.java b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/compress/TestCompressionDictionary.java
new file mode 100644
index 0000000000..7341bad361
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/compress/TestCompressionDictionary.java
@@ -0,0 +1,235 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.entity.compress;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+class TestCompressionDictionary {
+
+ private static final byte[] CONTENT = "the quick brown fox".getBytes(StandardCharsets.UTF_8);
+ private static final URI SOURCE = URI.create("https://example.com/dict.bin");
+ private static final String MATCH = "/*";
+ private static final String ID = "dict-1";
+ private static final Instant STORED_AT = Instant.parse("2026-01-01T00:00:00Z");
+ private static final Instant VALID_UNTIL = Instant.parse("2026-02-01T00:00:00Z");
+
+ private static CompressionDictionary newDictionary() {
+ return new CompressionDictionary(CONTENT, SOURCE, MATCH, ID, STORED_AT, VALID_UNTIL);
+ }
+
+ private static byte[] sha256(final byte[] content) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(content);
+ } catch (final NoSuchAlgorithmException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ @Test
+ void constructorRejectsNullContent() {
+ assertThrows(NullPointerException.class, () ->
+ new CompressionDictionary(null, SOURCE, MATCH, ID, STORED_AT, VALID_UNTIL));
+ }
+
+ @Test
+ void constructorRejectsNullSource() {
+ assertThrows(NullPointerException.class, () ->
+ new CompressionDictionary(CONTENT, null, MATCH, ID, STORED_AT, VALID_UNTIL));
+ }
+
+ @Test
+ void constructorRejectsNullMatch() {
+ assertThrows(NullPointerException.class, () ->
+ new CompressionDictionary(CONTENT, SOURCE, null, ID, STORED_AT, VALID_UNTIL));
+ }
+
+ @Test
+ void constructorRejectsBlankMatch() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new CompressionDictionary(CONTENT, SOURCE, " ", ID, STORED_AT, VALID_UNTIL));
+ }
+
+ @Test
+ void constructorRejectsNonHttpsOrRelativeSource() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new CompressionDictionary(CONTENT, URI.create("http://example.com/dict"),
+ MATCH, ID, STORED_AT, VALID_UNTIL));
+ assertThrows(IllegalArgumentException.class, () ->
+ new CompressionDictionary(CONTENT, URI.create("/dict"),
+ MATCH, ID, STORED_AT, VALID_UNTIL));
+ }
+
+ @Test
+ void constructorRejectsInvalidStructuredFieldStrings() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new CompressionDictionary(CONTENT, SOURCE, "/caf\u00e9", ID, STORED_AT, VALID_UNTIL));
+ assertThrows(IllegalArgumentException.class, () ->
+ new CompressionDictionary(CONTENT, SOURCE, MATCH, "bad\nid", STORED_AT, VALID_UNTIL));
+ assertThrows(IllegalArgumentException.class, () ->
+ new CompressionDictionary(CONTENT, SOURCE, MATCH,
+ Arrays.asList("document", null), ID, "raw", STORED_AT, VALID_UNTIL));
+ }
+
+ @Test
+ void constructorRejectsOversizedId() {
+ final char[] chars = new char[1025];
+ Arrays.fill(chars, 'x');
+ assertThrows(IllegalArgumentException.class, () ->
+ new CompressionDictionary(CONTENT, SOURCE, MATCH,
+ new String(chars), STORED_AT, VALID_UNTIL));
+ }
+
+ @Test
+ void constructorRejectsInvalidTypeToken() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new CompressionDictionary(CONTENT, SOURCE, MATCH,
+ null, ID, "raw type", STORED_AT, VALID_UNTIL));
+ }
+
+ @Test
+ void constructorRejectsNullStoredAt() {
+ assertThrows(NullPointerException.class, () ->
+ new CompressionDictionary(CONTENT, SOURCE, MATCH, ID, null, VALID_UNTIL));
+ }
+
+ @Test
+ void constructorRejectsNullValidUntil() {
+ assertThrows(NullPointerException.class, () ->
+ new CompressionDictionary(CONTENT, SOURCE, MATCH, ID, STORED_AT, null));
+ }
+
+ @Test
+ void nullIdBecomesEmptyString() {
+ final CompressionDictionary dict =
+ new CompressionDictionary(CONTENT, SOURCE, MATCH, null, STORED_AT, VALID_UNTIL);
+ assertEquals("", dict.getId());
+ }
+
+ @Test
+ void gettersReturnPassedValues() {
+ final CompressionDictionary dict = newDictionary();
+ assertEquals(SOURCE, dict.getSource());
+ assertEquals(MATCH, dict.getMatch());
+ assertEquals(ID, dict.getId());
+ assertEquals(STORED_AT, dict.getStoredAt());
+ assertEquals(VALID_UNTIL, dict.getValidUntil());
+ assertArrayEquals(CONTENT, dict.getContent());
+ }
+
+ @Test
+ void constructorClonesContent() {
+ final byte[] mutable = CONTENT.clone();
+ final CompressionDictionary dict =
+ new CompressionDictionary(mutable, SOURCE, MATCH, ID, STORED_AT, VALID_UNTIL);
+ mutable[0] = (byte) (mutable[0] ^ 0xFF);
+ assertArrayEquals(CONTENT, dict.getContent());
+ }
+
+ @Test
+ void getContentReturnsDefensiveCopy() {
+ final CompressionDictionary dict = newDictionary();
+ final byte[] first = dict.getContent();
+ first[0] = (byte) (first[0] ^ 0xFF);
+ assertArrayEquals(CONTENT, dict.getContent());
+ }
+
+ @Test
+ void getContentReturnsDistinctArrays() {
+ final CompressionDictionary dict = newDictionary();
+ final byte[] first = dict.getContent();
+ final byte[] second = dict.getContent();
+ assertNotSame(first, second);
+ assertArrayEquals(first, second);
+ }
+
+ @Test
+ void getSha256ReturnsDefensiveCopy() {
+ final CompressionDictionary dict = newDictionary();
+ final byte[] first = dict.getSha256();
+ first[0] = (byte) (first[0] ^ 0xFF);
+ assertArrayEquals(sha256(CONTENT), dict.getSha256());
+ }
+
+ @Test
+ void getSha256ReturnsDistinctArrays() {
+ final CompressionDictionary dict = newDictionary();
+ final byte[] first = dict.getSha256();
+ final byte[] second = dict.getSha256();
+ assertNotSame(first, second);
+ assertArrayEquals(first, second);
+ }
+
+ @Test
+ void sha256MatchesFreshlyComputedDigest() {
+ final CompressionDictionary dict = newDictionary();
+ assertArrayEquals(sha256(CONTENT), dict.getSha256());
+ }
+
+ @Test
+ void matchesHashTrueForSha256() {
+ final CompressionDictionary dict = newDictionary();
+ assertTrue(dict.matchesHash(sha256(CONTENT)));
+ }
+
+ @Test
+ void matchesHashFalseForOtherHash() {
+ final CompressionDictionary dict = newDictionary();
+ assertFalse(dict.matchesHash(sha256("different".getBytes(StandardCharsets.UTF_8))));
+ }
+
+ @Test
+ void isFreshBeforeValidUntil() {
+ final CompressionDictionary dict = newDictionary();
+ assertTrue(dict.isFresh(VALID_UNTIL.minusSeconds(1)));
+ }
+
+ @Test
+ void isFreshAtValidUntil() {
+ final CompressionDictionary dict = newDictionary();
+ assertFalse(dict.isFresh(VALID_UNTIL));
+ }
+
+ @Test
+ void isFreshAfterValidUntil() {
+ final CompressionDictionary dict = newDictionary();
+ assertFalse(dict.isFresh(VALID_UNTIL.plusSeconds(1)));
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientCompressionDictionary.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientCompressionDictionary.java
new file mode 100644
index 0000000000..e29d2e953a
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientCompressionDictionary.java
@@ -0,0 +1,223 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.examples;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.entity.compress.BasicCompressionDictionaryStore;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.Message;
+import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityConsumer;
+import org.apache.hc.core5.http.nio.support.BasicRequestProducer;
+import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
+
+/**
+ * Async client example for RFC 9842 Compression Dictionary Transport.
+ *
+ * The example uses {@code canicompress.com}, which publishes a new JavaScript
+ * bundle every minute. The previous bundle is used as a compression dictionary
+ * for the current bundle.
+ *
+ * The client:
+ *
+ * - Fetches the two most recent bundle URLs from the manifest.
+ * - Fetches the previous bundle and stores it as a compression dictionary.
+ * - Fetches the current bundle using the negotiated dictionary.
+ * - Transparently decompresses the {@code dcz} response.
+ *
+ */
+public final class AsyncClientCompressionDictionary {
+
+ private static final String ORIGIN = "https://canicompress.com";
+ private static final URI MANIFEST_URI = URI.create(ORIGIN + "/manifest.json");
+
+ public static void main(final String[] args) throws Exception {
+ final BasicCompressionDictionaryStore dictionaryStore =
+ new BasicCompressionDictionaryStore();
+ final HttpClientContext clientContext = HttpClientContext.create();
+
+ try (final CloseableHttpAsyncClient client = HttpAsyncClients.custom()
+ .setCompressionDictionaryStore(dictionaryStore)
+ .build()) {
+
+ client.start();
+
+ final Message manifestResponse =
+ execute(client, clientContext, MANIFEST_URI);
+
+ final String manifest = new String(
+ manifestResponse.getBody(),
+ StandardCharsets.UTF_8);
+
+ final List paths = extractDeployPaths(manifest);
+ if (paths.size() < 2) {
+ throw new IllegalStateException(
+ "Unable to find two recent deploys in manifest");
+ }
+
+ final long cacheBuster = System.currentTimeMillis();
+
+ final URI dictionaryUri = URI.create(
+ ORIGIN + paths.get(1) + "?t=" + cacheBuster + "a");
+
+ final URI resourceUri = URI.create(
+ ORIGIN + paths.get(0) + "?t=" + cacheBuster + "b");
+
+ System.out.println("Dictionary:");
+ System.out.println(" " + dictionaryUri);
+
+ System.out.println();
+ System.out.println("Resource:");
+ System.out.println(" " + resourceUri);
+
+ System.out.println();
+ System.out.println("Fetching dictionary...");
+
+ final Message dictionaryResponse =
+ execute(client, clientContext, dictionaryUri);
+
+ final HttpResponse dictionaryHead = dictionaryResponse.getHead();
+
+ System.out.println("Status : " + dictionaryHead.getCode());
+
+ final Header useAsDictionary =
+ dictionaryHead.getFirstHeader("Use-As-Dictionary");
+
+ System.out.println("Use-As-Dictionary : "
+ + (useAsDictionary != null
+ ? useAsDictionary.getValue()
+ : "(none)"));
+
+ System.out.println("Dictionary bytes : "
+ + (dictionaryResponse.getBody() != null
+ ? dictionaryResponse.getBody().length
+ : 0));
+
+ System.out.println("Stored dictionaries: "
+ + dictionaryStore.getByOrigin(
+ clientContext.getCookieStore(), dictionaryUri).size());
+
+ System.out.println();
+ System.out.println("Fetching current resource...");
+
+ final Message resourceResponse =
+ execute(client, clientContext, resourceUri);
+
+ final HttpResponse resourceHead = resourceResponse.getHead();
+
+ System.out.println("Status : " + resourceHead.getCode());
+
+ final Header contentEncoding =
+ resourceHead.getFirstHeader(HttpHeaders.CONTENT_ENCODING);
+
+ System.out.println("Content-Encoding : "
+ + (contentEncoding != null
+ ? contentEncoding.getValue()
+ : "(none)"));
+
+ final byte[] body = resourceResponse.getBody() != null
+ ? resourceResponse.getBody()
+ : new byte[0];
+
+ System.out.println("Decoded bytes : " + body.length);
+
+ final String text = new String(body, StandardCharsets.UTF_8);
+
+ System.out.println("Response prefix : "
+ + text.substring(0, Math.min(text.length(), 120))
+ .replace('\n', ' '));
+ }
+ }
+
+ private static Message execute(
+ final CloseableHttpAsyncClient client,
+ final HttpClientContext context,
+ final URI uri) throws Exception {
+
+ final SimpleHttpRequest request = SimpleRequestBuilder.get(uri)
+ .build();
+
+ final Future> future = client.execute(
+ new BasicRequestProducer(request, null),
+ new BasicResponseConsumer<>(new BasicAsyncEntityConsumer()),
+ context,
+ null);
+
+ return future.get();
+ }
+
+ private static List extractDeployPaths(final String manifest) {
+ final List paths = new ArrayList<>();
+
+ int offset = manifest.indexOf("\"recent_deploys\"");
+ if (offset < 0) {
+ return paths;
+ }
+
+ while (true) {
+ final int name = manifest.indexOf("\"path\"", offset);
+ if (name < 0) {
+ break;
+ }
+
+ final int colon = manifest.indexOf(':', name);
+ if (colon < 0) {
+ break;
+ }
+
+ final int start = manifest.indexOf('"', colon + 1);
+ if (start < 0) {
+ break;
+ }
+
+ final int end = manifest.indexOf('"', start + 1);
+ if (end < 0) {
+ break;
+ }
+
+ final String path = manifest.substring(start + 1, end);
+ if (!paths.contains(path)) {
+ paths.add(path);
+ }
+
+ offset = end + 1;
+ }
+
+ return paths;
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientServerDictionaryBrotli.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientServerDictionaryBrotli.java
new file mode 100644
index 0000000000..52ade1bd0b
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientServerDictionaryBrotli.java
@@ -0,0 +1,596 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.examples;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.KeyStore;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.Base64;
+import java.util.concurrent.Future;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
+
+import com.aayushatharva.brotli4j.Brotli4jLoader;
+import com.aayushatharva.brotli4j.encoder.BrotliOutputStream;
+import com.aayushatharva.brotli4j.encoder.Encoder;
+import com.aayushatharva.brotli4j.encoder.PreparedDictionary;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.entity.compress.BasicCompressionDictionaryStore;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Message;
+import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
+import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap;
+import org.apache.hc.core5.http.io.HttpRequestHandler;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
+import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityConsumer;
+import org.apache.hc.core5.http.nio.support.BasicRequestProducer;
+import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+
+/**
+ * End-to-end RFC 9842 Dictionary-Compressed Brotli ({@code dcb}) example.
+ *
+ * A local HTTPS server first serves a resource marked with
+ * {@code Use-As-Dictionary}. The asynchronous client stores that response
+ * as a compression dictionary.
+ *
+ * A subsequent matching request advertises the dictionary through
+ * {@code Available-Dictionary}. The server then returns a real
+ * Dictionary-Compressed Brotli response and the client transparently
+ * decompresses it.
+ *
+ * The TLS certificate embedded in this example is for localhost testing only.
+ */
+public final class AsyncClientServerDictionaryBrotli {
+
+ private static final String DCB = "dcb";
+
+ private static final byte[] DCB_MAGIC = {
+ (byte) 0xff, 0x44, 0x43, 0x42
+ };
+
+ private static final byte[] DICTIONARY = (
+ "{"
+ + "\"application\":\"Apache HttpClient\","
+ + "\"feature\":\"Compression Dictionary Transport\","
+ + "\"version\":1,"
+ + "\"message\":\"This document is used as a shared compression dictionary.\","
+ + "\"description\":\"Apache HttpClient supports transparent asynchronous "
+ + "HTTP content decompression using gzip, deflate, Brotli and Zstandard.\""
+ + "}"
+ ).getBytes(StandardCharsets.UTF_8);
+
+ private static final byte[] RESOURCE = (
+ "{"
+ + "\"application\":\"Apache HttpClient\","
+ + "\"feature\":\"Compression Dictionary Transport\","
+ + "\"version\":2,"
+ + "\"message\":\"This document is compressed using the previous response "
+ + "as a shared compression dictionary.\","
+ + "\"description\":\"Apache HttpClient supports transparent asynchronous "
+ + "HTTP content decompression using gzip, deflate, Brotli and Zstandard.\""
+ + "}"
+ ).getBytes(StandardCharsets.UTF_8);
+
+ /*
+ * Test-only localhost certificate.
+ */
+ private static final String CERTIFICATE =
+ "-----BEGIN CERTIFICATE-----\n"
+ + "MIIDJTCCAg2gAwIBAgIUcY/j5gVTlIfg/yW+kjv4Pg7q8mYwDQYJKoZIhvcNAQEL\n"
+ + "BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDgyMTExMTE1M1oXDTM2MDgx\n"
+ + "ODExMTE1M1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF\n"
+ + "AAOCAQ8AMIIBCgKCAQEApiAxQTbyBOrja4j73l2RqCDRODtg7+6rziV2T31UPhNF\n"
+ + "L3gcn6ApTJdp9roR89ndYHvN/OD49n3eryD5AEf/KcuMuHsFYUFodmUXhLk54ndl\n"
+ + "REw3iDl/4DTFcaRzRgAK/yVtlp6mYe6pRiEv7oR9oBXb7g3VzPvYpJ6ZenX1xuiz\n"
+ + "t5CDpZj4lmFoUHGxbcj02UOl4CCXTSqYIM6ibt9Y6EjdxxEzz9DfCkIw1z9lRpp5\n"
+ + "wkcvauZTokWQO9kmYQPsaHYwcWVYd7ahXo8d67sgDMXSiyqvUYgcog298iC8K+o0\n"
+ + "ZaAOxQqfFj+ZAamUVHwUlJPa7WLP17ev8EQDfBmi1wIDAQABo28wbTAdBgNVHQ4E\n"
+ + "FgQUESRWkI3fjUV4Yck000U1MlsPx1IwHwYDVR0jBBgwFoAUESRWkI3fjUV4Yck0\n"
+ + "00U1MlsPx1IwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH\n"
+ + "BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAHbtKlv5rZq3pu3yFj/apIIQyapQo2Gn\n"
+ + "8Qhw6IRFEZ+SYP/DVxYpEY8TlJy+XxSkSeDqpgGdYKG4Ljt762KF95rsxUAg+IcC\n"
+ + "6s/wDSpQ9GMTdGRGSeGd/hR1O0MuqCiIBijYK+MjutKZOOvMSsdE0wjhUCoWNIOS\n"
+ + "AlMzBJL1Np5dDs8Dqne/tNA8MZ/Dsx9gmo4Fv1JuIHWTCjG87CpZB78+G+QOhJWF\n"
+ + "6ubYTaNX/V/GW+cFX7XlF5iWy1o9TlBHMmbNos/za3FIRIEc45vrK4sM/0U1P8cJ\n"
+ + "eNR5rHEXdQX7C0AEHs2Xs29D0k7egK9AqoDWGihzTcwGItFwIf1+ZbI=\n"
+ + "-----END CERTIFICATE-----";
+
+ private static final String PRIVATE_KEY =
+ "-----BEGIN PRIVATE KEY-----\n"
+ + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmIDFBNvIE6uNr\n"
+ + "iPveXZGoINE4O2Dv7qvOJXZPfVQ+E0UveByfoClMl2n2uhHz2d1ge8384Pj2fd6v\n"
+ + "IPkAR/8py4y4ewVhQWh2ZReEuTnid2VETDeIOX/gNMVxpHNGAAr/JW2WnqZh7qlG\n"
+ + "IS/uhH2gFdvuDdXM+9iknpl6dfXG6LO3kIOlmPiWYWhQcbFtyPTZQ6XgIJdNKpgg\n"
+ + "zqJu31joSN3HETPP0N8KQjDXP2VGmnnCRy9q5lOiRZA72SZhA+xodjBxZVh3tqFe\n"
+ + "jx3ruyAMxdKLKq9RiByiDb3yILwr6jRloA7FCp8WP5kBqZRUfBSUk9rtYs/Xt6/w\n"
+ + "RAN8GaLXAgMBAAECggEAMRb2NxUrczSNu2shMlZoAkygRoOVY5Edh68eROL+D9HV\n"
+ + "8e8GVk0XpyBfGZ9mSq6ocihjeERqjTwon4uYyPJ9fjY+AQ2pS1Husn2w83Fgn4E0\n"
+ + "lXgIOOL03KX7ald0EM1Wcor21TlQZUQHFUgdR9gy3ylWcgP4l7gcDpknNT7CP+Jt\n"
+ + "rkNsifYFES2/lRYyFDbZBqV4Qjc2L+iik50qM+rj5B7DtjTns97+/Ck/ke1vGMyY\n"
+ + "waaqrOg0MH2O/LWXfHaqAUfEiq0AfQRqnqPKAofNbCTlFSEVKrH21bpRzF8kfNaR\n"
+ + "n62KVWSYN+7WbXAdmyoIiDw8McXW/0zFKppAoOWkAQKBgQDWwNxUyQz8XtNRoU6l\n"
+ + "aW0Z/lDxhNlDHwoRxTRKtnaLhaNJO/UoF2HGu/LDAiS29xUsyULyE3ViSVc/TgF9\n"
+ + "k74ou+KqfIOjNKN2xJ+NvDmfz4am7TmjM9LggPZii8yIMu9BXUJMOmDgkecm2Dzb\n"
+ + "EUbfTw0YcD2xpDl5jaU5V+QD1wKBgQDGCGBWV3n+ZUF2AkLc7OVek5XKA3VTZg8m\n"
+ + "CmN1qQ+ThoO94TXS+VtO1Rarhfz6Nhdggdder4VxuLgthmHtkQtox90GBE0VjJ0Z\n"
+ + "RTkGrUY6NJjpVhtsdZmB7gzrlXtmO0yp0oXdyXe6fc0jjsf5YmjYfy1/wk57wB7K\n"
+ + "jXUo6CN5AQKBgA+z2mh4qvJpHJqDaPS/WLLl3ZVLWXeG9X2HJeOwo8pf4yifsbVU\n"
+ + "wFl/tKh9p6GZP3se3D5HHfYp1q9STNmZy/W+hzxgDmAIoUs15VS/xpbg3b+m6Of+\n"
+ + "ChVQWLOr9TCgSM5Gu2pHen3xLS2x8gEyqjP528NFsb0jfPBeYw5mVs3RAoGBALlf\n"
+ + "9e5dDJGa72AsVbLA/yU9OiZUfmuHSf7uEpR9oVsTvBbuzpejXFm7FvGRB3KhV9i7\n"
+ + "MoQsAdqmc6IJ/XmJIQkArmGHfTEC47xYFD2vzeGGgu1J8Xnhy8TYtbeBwnW8ZNND\n"
+ + "gpROl4k3YeQ7L+6+tC6VPl4t4ZHuEeTB7j5Qr4QBAoGBAKpm9gLtvNYiCRvQjE2x\n"
+ + "vspx6yBa4z9E9Zzd98jXwIgWmbeN2oLuWXbHej4aBNCTlcoJhOof5wOlEUQhyjf9\n"
+ + "584Yj4SGVyXFWk3LOBJvr4iHfnm8uNxZxCZBqIwAZsK4e/02ExuT7Qpvh3kkn435\n"
+ + "wBYccyXfapxyL80kzfYp7bqb\n"
+ + "-----END PRIVATE KEY-----";
+
+ static {
+ Brotli4jLoader.ensureAvailability();
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final Certificate certificate = loadCertificate();
+ final SSLContext serverSslContext =
+ createServerSslContext(certificate, loadPrivateKey());
+ final SSLContext clientSslContext =
+ createClientSslContext(certificate);
+
+ final byte[] dictionaryHash =
+ MessageDigest.getInstance("SHA-256").digest(DICTIONARY);
+
+ final HttpServer server = ServerBootstrap.bootstrap()
+ .setLocalAddress(InetAddress.getLoopbackAddress())
+ .setListenerPort(0)
+ .setCanonicalHostName("localhost")
+ .setSslContext(serverSslContext)
+ .register("/dictionary", new DictionaryHandler())
+ .register("/resource", new ResourceHandler(dictionaryHash))
+ .create();
+
+ server.start();
+
+ final int port = server.getLocalPort();
+
+ final URI dictionaryUri =
+ URI.create("https://localhost:" + port + "/dictionary");
+
+ final URI resourceUri =
+ URI.create("https://localhost:" + port + "/resource");
+
+ final BasicCompressionDictionaryStore dictionaryStore =
+ new BasicCompressionDictionaryStore();
+ final HttpClientContext clientContext = HttpClientContext.create();
+
+ final PoolingAsyncClientConnectionManager connectionManager =
+ PoolingAsyncClientConnectionManagerBuilder.create()
+ .setTlsStrategy(new DefaultClientTlsStrategy(clientSslContext))
+ .build();
+
+ try (final CloseableHttpAsyncClient client = HttpAsyncClients.custom()
+ .setConnectionManager(connectionManager)
+ .setCompressionDictionaryStore(dictionaryStore)
+ .build()) {
+
+ client.start();
+
+ System.out.println("Fetching dictionary:");
+ System.out.println(" " + dictionaryUri);
+
+ final Message dictionaryResponse =
+ execute(client, clientContext, dictionaryUri);
+
+ final HttpResponse dictionaryHead =
+ dictionaryResponse.getHead();
+
+ final Header useAsDictionary =
+ dictionaryHead.getFirstHeader("Use-As-Dictionary");
+
+ System.out.println("Status : "
+ + dictionaryHead.getCode());
+
+ System.out.println("Use-As-Dictionary : "
+ + (useAsDictionary != null
+ ? useAsDictionary.getValue()
+ : "(none)"));
+
+ System.out.println("Dictionary bytes : "
+ + dictionaryResponse.getBody().length);
+
+ System.out.println("Stored dictionaries: "
+ + dictionaryStore.getByOrigin(
+ clientContext.getCookieStore(), dictionaryUri).size());
+
+ System.out.println();
+ System.out.println("Fetching DCB resource:");
+ System.out.println(" " + resourceUri);
+
+ final Message resourceResponse =
+ execute(client, clientContext, resourceUri);
+
+ final HttpResponse resourceHead =
+ resourceResponse.getHead();
+
+ final Header contentEncoding =
+ resourceHead.getFirstHeader(HttpHeaders.CONTENT_ENCODING);
+
+ final byte[] decoded = resourceResponse.getBody();
+
+ System.out.println("Status : "
+ + resourceHead.getCode());
+
+ System.out.println("Content-Encoding : "
+ + (contentEncoding != null
+ ? contentEncoding.getValue()
+ : "(none)"));
+
+ System.out.println("Decoded bytes : " + decoded.length);
+
+ System.out.println("Response (plain) : "
+ + new String(decoded, StandardCharsets.UTF_8));
+
+ if (!MessageDigest.isEqual(RESOURCE, decoded)) {
+ throw new IllegalStateException(
+ "Decoded DCB response does not match original resource");
+ }
+
+ if (contentEncoding == null
+ || !DCB.equalsIgnoreCase(contentEncoding.getValue())) {
+ throw new IllegalStateException(
+ "Server did not return a DCB response");
+ }
+
+ System.out.println();
+ System.out.println("DCB round-trip successful.");
+ } finally {
+ server.close(CloseMode.GRACEFUL);
+ }
+ }
+
+ private static Message execute(
+ final CloseableHttpAsyncClient client,
+ final HttpClientContext context,
+ final URI uri) throws Exception {
+
+ final SimpleHttpRequest request =
+ SimpleRequestBuilder.get(uri).build();
+
+ final Future> future =
+ client.execute(
+ new BasicRequestProducer(request, null),
+ new BasicResponseConsumer<>(
+ new BasicAsyncEntityConsumer()),
+ context,
+ null);
+
+ return future.get();
+ }
+
+ private static final class DictionaryHandler
+ implements HttpRequestHandler {
+
+ @Override
+ public void handle(
+ final ClassicHttpRequest request,
+ final ClassicHttpResponse response,
+ final HttpContext context) {
+
+ response.setCode(HttpStatus.SC_OK);
+
+ response.addHeader(
+ HttpHeaders.CACHE_CONTROL,
+ "max-age=3600");
+
+ response.addHeader(
+ "Use-As-Dictionary",
+ "match=\"/resource\", id=\"local-dcb-v1\"");
+
+ response.setEntity(
+ new ByteArrayEntity(
+ DICTIONARY,
+ ContentType.APPLICATION_JSON));
+ }
+ }
+
+ private static final class ResourceHandler
+ implements HttpRequestHandler {
+
+ private final byte[] dictionaryHash;
+ private final String availableDictionary;
+
+ ResourceHandler(final byte[] dictionaryHash) {
+ this.dictionaryHash = dictionaryHash.clone();
+ this.availableDictionary =
+ ":" + Base64.getEncoder()
+ .encodeToString(dictionaryHash) + ":";
+ }
+
+ @Override
+ public void handle(
+ final ClassicHttpRequest request,
+ final ClassicHttpResponse response,
+ final HttpContext context) throws IOException {
+
+ final Header available =
+ request.getFirstHeader("Available-Dictionary");
+
+ final Header acceptEncoding =
+ request.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
+
+ System.out.println();
+ System.out.println("Server received:");
+ System.out.println("Available-Dictionary: "
+ + (available != null
+ ? available.getValue()
+ : "(none)"));
+ System.out.println("Accept-Encoding : "
+ + (acceptEncoding != null
+ ? acceptEncoding.getValue()
+ : "(none)"));
+
+ final boolean dictionaryMatches =
+ available != null
+ && availableDictionary.equals(
+ available.getValue());
+
+ final boolean acceptsDcb =
+ acceptEncoding != null
+ && containsToken(
+ acceptEncoding.getValue(), DCB);
+
+ if (!dictionaryMatches || !acceptsDcb) {
+ response.setCode(HttpStatus.SC_OK);
+ response.setEntity(
+ new ByteArrayEntity(
+ RESOURCE,
+ ContentType.APPLICATION_JSON));
+ return;
+ }
+
+ final byte[] compressed =
+ createDcb(
+ DICTIONARY,
+ dictionaryHash,
+ RESOURCE);
+
+ response.setCode(HttpStatus.SC_OK);
+ response.addHeader(
+ HttpHeaders.CONTENT_ENCODING,
+ DCB);
+
+ response.addHeader(
+ HttpHeaders.VARY,
+ "Accept-Encoding, Available-Dictionary");
+
+ response.setEntity(
+ new ByteArrayEntity(
+ compressed,
+ ContentType.APPLICATION_OCTET_STREAM));
+ }
+ }
+
+ private static byte[] createDcb(
+ final byte[] dictionary,
+ final byte[] dictionaryHash,
+ final byte[] content) throws IOException {
+
+ final ByteBuffer dictionaryBuffer =
+ ByteBuffer.allocateDirect(dictionary.length);
+
+ dictionaryBuffer.put(dictionary);
+ dictionaryBuffer.flip();
+
+ /*
+ * Shared dictionary type 0 is a raw LZ77 prefix dictionary.
+ */
+ final PreparedDictionary preparedDictionary =
+ Encoder.prepareDictionary(dictionaryBuffer, 0);
+
+ final ByteArrayOutputStream compressed =
+ new ByteArrayOutputStream();
+
+ try {
+ final Encoder.Parameters parameters =
+ Encoder.Parameters.create(
+ 6,
+ 24,
+ Encoder.Mode.TEXT);
+
+ try (final BrotliOutputStream out =
+ new BrotliOutputStream(
+ compressed,
+ parameters)) {
+
+ out.attachDictionary(preparedDictionary);
+ out.write(content);
+ }
+ } finally {
+ if (preparedDictionary instanceof AutoCloseable) {
+ try {
+ ((AutoCloseable) preparedDictionary).close();
+ } catch (final Exception ex) {
+ throw new IOException(
+ "Unable to release Brotli dictionary",
+ ex);
+ }
+ }
+ }
+
+ final ByteArrayOutputStream dcb =
+ new ByteArrayOutputStream(
+ DCB_MAGIC.length
+ + dictionaryHash.length
+ + compressed.size());
+
+ dcb.write(DCB_MAGIC);
+ dcb.write(dictionaryHash);
+ compressed.writeTo(dcb);
+
+ return dcb.toByteArray();
+ }
+
+ private static boolean containsToken(
+ final String value,
+ final String token) {
+
+ final String[] tokens = value.split(",");
+
+ for (final String current : tokens) {
+ if (token.equalsIgnoreCase(current.trim())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static Certificate loadCertificate()
+ throws Exception {
+
+ final String value = CERTIFICATE
+ .replace("-----BEGIN CERTIFICATE-----", "")
+ .replace("-----END CERTIFICATE-----", "")
+ .replaceAll("\\s", "");
+
+ final byte[] encoded =
+ Base64.getDecoder().decode(value);
+
+ final CertificateFactory factory =
+ CertificateFactory.getInstance("X.509");
+
+ return factory.generateCertificate(
+ new ByteArrayInputStream(encoded));
+ }
+
+ private static PrivateKey loadPrivateKey()
+ throws Exception {
+
+ final String value = PRIVATE_KEY
+ .replace("-----BEGIN PRIVATE KEY-----", "")
+ .replace("-----END PRIVATE KEY-----", "")
+ .replaceAll("\\s", "");
+
+ final byte[] encoded =
+ Base64.getDecoder().decode(value);
+
+ return KeyFactory.getInstance("RSA")
+ .generatePrivate(
+ new PKCS8EncodedKeySpec(encoded));
+ }
+
+ private static SSLContext createServerSslContext(
+ final Certificate certificate,
+ final PrivateKey privateKey) throws Exception {
+
+ final char[] password = "changeit".toCharArray();
+
+ final KeyStore keyStore =
+ KeyStore.getInstance(KeyStore.getDefaultType());
+
+ keyStore.load(null, null);
+
+ keyStore.setKeyEntry(
+ "localhost",
+ privateKey,
+ password,
+ new Certificate[]{certificate});
+
+ final KeyManagerFactory keyManagerFactory =
+ KeyManagerFactory.getInstance(
+ KeyManagerFactory.getDefaultAlgorithm());
+
+ keyManagerFactory.init(keyStore, password);
+
+ final SSLContext sslContext =
+ SSLContext.getInstance("TLS");
+
+ sslContext.init(
+ keyManagerFactory.getKeyManagers(),
+ null,
+ null);
+
+ return sslContext;
+ }
+
+ private static SSLContext createClientSslContext(
+ final Certificate certificate) throws Exception {
+
+ final KeyStore trustStore =
+ KeyStore.getInstance(KeyStore.getDefaultType());
+
+ trustStore.load(null, null);
+
+ trustStore.setCertificateEntry(
+ "localhost",
+ certificate);
+
+ final TrustManagerFactory trustManagerFactory =
+ TrustManagerFactory.getInstance(
+ TrustManagerFactory.getDefaultAlgorithm());
+
+ trustManagerFactory.init(trustStore);
+
+ final SSLContext sslContext =
+ SSLContext.getInstance("TLS");
+
+ sslContext.init(
+ null,
+ trustManagerFactory.getTrustManagers(),
+ null);
+
+ return sslContext;
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestCompressionDictionaryCookieStore.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestCompressionDictionaryCookieStore.java
new file mode 100644
index 0000000000..8d8980f608
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestCompressionDictionaryCookieStore.java
@@ -0,0 +1,69 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+
+import org.apache.hc.client5.http.cookie.BasicCookieStore;
+import org.apache.hc.client5.http.cookie.CookieStore;
+import org.apache.hc.client5.http.entity.compress.BasicCompressionDictionaryStore;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.client5.http.impl.cookie.BasicClientCookie;
+import org.junit.jupiter.api.Test;
+
+class TestCompressionDictionaryCookieStore {
+
+ @Test
+ void testClearRemovesCookiesAndCompressionDictionaries() {
+ final BasicCompressionDictionaryStore dictionaryStore =
+ new BasicCompressionDictionaryStore();
+ final CookieStore cookieStore = new CompressionDictionaryCookieStore(
+ new BasicCookieStore(), dictionaryStore);
+ cookieStore.addCookie(new BasicClientCookie("name", "value"));
+ final Instant now = Instant.now();
+ final URI source = URI.create("https://example.com/dictionary");
+ dictionaryStore.add(cookieStore, new CompressionDictionary(
+ "dictionary".getBytes(StandardCharsets.UTF_8),
+ source,
+ "/*",
+ "",
+ now,
+ now.plusSeconds(60)));
+ assertFalse(cookieStore.getCookies().isEmpty());
+
+ cookieStore.clear();
+
+ assertTrue(cookieStore.getCookies().isEmpty());
+ assertTrue(dictionaryStore.getByOrigin(cookieStore, source).isEmpty());
+ }
+
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestCompressionDictionaryFreshness.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestCompressionDictionaryFreshness.java
new file mode 100644
index 0000000000..589d578fe6
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestCompressionDictionaryFreshness.java
@@ -0,0 +1,120 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.time.Instant;
+
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.junit.jupiter.api.Test;
+
+class TestCompressionDictionaryFreshness {
+
+ private static final Instant REQUEST_TIME = Instant.parse("2026-08-21T11:59:58Z");
+ private static final Instant RESPONSE_TIME = Instant.parse("2026-08-21T12:00:00Z");
+
+ @Test
+ void testMaxAgeUsesCorrectedInitialAge() {
+ final BasicHttpResponse response = new BasicHttpResponse(200);
+ response.addHeader(HttpHeaders.CACHE_CONTROL, "max-age=60");
+ response.addHeader(HttpHeaders.DATE, "Fri, 21 Aug 2026 11:59:50 GMT");
+ response.addHeader(HttpHeaders.AGE, "5");
+
+ assertEquals(Instant.parse("2026-08-21T12:00:50Z"),
+ CompressionDictionaryFreshness.determineValidUntil(
+ response, REQUEST_TIME, RESPONSE_TIME));
+ }
+
+ @Test
+ void testExpiresSuppliesFreshnessLifetime() {
+ final BasicHttpResponse response = new BasicHttpResponse(200);
+ response.addHeader(HttpHeaders.DATE, "Fri, 21 Aug 2026 12:00:00 GMT");
+ response.addHeader(HttpHeaders.EXPIRES, "Fri, 21 Aug 2026 12:01:00 GMT");
+
+ assertEquals(Instant.parse("2026-08-21T12:00:58Z"),
+ CompressionDictionaryFreshness.determineValidUntil(
+ response, REQUEST_TIME, RESPONSE_TIME));
+ }
+
+ @Test
+ void testObsoleteHttpDateFormatsAreAccepted() {
+ final BasicHttpResponse rfc850 = new BasicHttpResponse(200);
+ rfc850.addHeader(HttpHeaders.DATE, "Friday, 21-Aug-26 12:00:00 GMT");
+ rfc850.addHeader(HttpHeaders.EXPIRES, "Friday, 21-Aug-26 12:01:00 GMT");
+ assertEquals(Instant.parse("2026-08-21T12:00:58Z"),
+ CompressionDictionaryFreshness.determineValidUntil(
+ rfc850, REQUEST_TIME, RESPONSE_TIME));
+
+ final BasicHttpResponse asctime = new BasicHttpResponse(200);
+ asctime.addHeader(HttpHeaders.DATE, "Fri Aug 21 12:00:00 2026");
+ asctime.addHeader(HttpHeaders.EXPIRES, "Fri Aug 21 12:01:00 2026");
+ assertEquals(Instant.parse("2026-08-21T12:00:58Z"),
+ CompressionDictionaryFreshness.determineValidUntil(
+ asctime, REQUEST_TIME, RESPONSE_TIME));
+ }
+
+ @Test
+ void testNoStoreIsNotStorable() {
+ final BasicHttpResponse response = new BasicHttpResponse(200);
+ response.addHeader(HttpHeaders.CACHE_CONTROL, "max-age=60, no-store");
+
+ assertNull(CompressionDictionaryFreshness.determineValidUntil(
+ response, REQUEST_TIME, RESPONSE_TIME));
+ }
+
+ @Test
+ void testNoCacheIsNotFresh() {
+ final BasicHttpResponse response = new BasicHttpResponse(200);
+ response.addHeader(HttpHeaders.CACHE_CONTROL, "max-age=60, no-cache");
+
+ assertNull(CompressionDictionaryFreshness.determineValidUntil(
+ response, REQUEST_TIME, RESPONSE_TIME));
+ }
+
+ @Test
+ void testMalformedMaxAgeFailsClosed() {
+ final BasicHttpResponse response = new BasicHttpResponse(200);
+ response.addHeader(HttpHeaders.CACHE_CONTROL, "max-age=invalid");
+
+ assertNull(CompressionDictionaryFreshness.determineValidUntil(
+ response, REQUEST_TIME, RESPONSE_TIME));
+ }
+
+ @Test
+ void testAlreadyStaleResponseIsNotEligible() {
+ final BasicHttpResponse response = new BasicHttpResponse(200);
+ response.addHeader(HttpHeaders.CACHE_CONTROL, "max-age=5");
+ response.addHeader(HttpHeaders.DATE, "Fri, 21 Aug 2026 11:59:50 GMT");
+
+ assertNull(CompressionDictionaryFreshness.determineValidUntil(
+ response, REQUEST_TIME, RESPONSE_TIME));
+ }
+
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestCompressionDictionaryHeaderSupport.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestCompressionDictionaryHeaderSupport.java
new file mode 100644
index 0000000000..c9561aaa74
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestCompressionDictionaryHeaderSupport.java
@@ -0,0 +1,103 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import org.junit.jupiter.api.Test;
+
+class TestCompressionDictionaryHeaderSupport {
+
+ @Test
+ void testHeaderNameConstants() {
+ assertEquals("Use-As-Dictionary", CompressionDictionaryHeaderSupport.USE_AS_DICTIONARY);
+ assertEquals("Available-Dictionary", CompressionDictionaryHeaderSupport.AVAILABLE_DICTIONARY);
+ assertEquals("Dictionary-ID", CompressionDictionaryHeaderSupport.DICTIONARY_ID);
+ }
+
+ @Test
+ void testFormatAvailableDictionaryKnownInput() {
+ final byte[] sha256 = "abc".getBytes(StandardCharsets.US_ASCII);
+ assertEquals(":YWJj:", CompressionDictionaryHeaderSupport.formatAvailableDictionary(sha256));
+ }
+
+ @Test
+ void testFormatAvailableDictionaryMatchesBase64() {
+ final byte[] sha256 = new byte[32];
+ for (int i = 0; i < sha256.length; i++) {
+ sha256[i] = (byte) i;
+ }
+ final String expected = ":" + Base64.getEncoder().encodeToString(sha256) + ":";
+ assertEquals(expected, CompressionDictionaryHeaderSupport.formatAvailableDictionary(sha256));
+ }
+
+ @Test
+ void testFormatAvailableDictionaryEmpty() {
+ assertEquals("::", CompressionDictionaryHeaderSupport.formatAvailableDictionary(new byte[0]));
+ }
+
+ @Test
+ void testFormatAvailableDictionaryNull() {
+ assertThrows(NullPointerException.class,
+ () -> CompressionDictionaryHeaderSupport.formatAvailableDictionary(null));
+ }
+
+ @Test
+ void testFormatDictionaryIdPlain() {
+ assertEquals("\"foo\"", CompressionDictionaryHeaderSupport.formatDictionaryId("foo"));
+ }
+
+ @Test
+ void testFormatDictionaryIdEmpty() {
+ assertEquals("\"\"", CompressionDictionaryHeaderSupport.formatDictionaryId(""));
+ }
+
+ @Test
+ void testFormatDictionaryIdEscapesQuotes() {
+ assertEquals("\"a\\\"b\"", CompressionDictionaryHeaderSupport.formatDictionaryId("a\"b"));
+ }
+
+ @Test
+ void testFormatDictionaryIdEscapesBackslash() {
+ assertEquals("\"a\\\\b\"", CompressionDictionaryHeaderSupport.formatDictionaryId("a\\b"));
+ }
+
+ @Test
+ void testFormatDictionaryIdEscapesBoth() {
+ assertEquals("\"\\\\\\\"\"", CompressionDictionaryHeaderSupport.formatDictionaryId("\\\""));
+ }
+
+ @Test
+ void testFormatDictionaryIdNull() {
+ assertThrows(NullPointerException.class,
+ () -> CompressionDictionaryHeaderSupport.formatDictionaryId(null));
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestContentCompressionAsyncExecDictionary.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestContentCompressionAsyncExecDictionary.java
new file mode 100644
index 0000000000..7d55d8c697
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestContentCompressionAsyncExecDictionary.java
@@ -0,0 +1,430 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.UnaryOperator;
+
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.async.AsyncExecCallback;
+import org.apache.hc.client5.http.async.AsyncExecChain;
+import org.apache.hc.client5.http.async.AsyncExecRuntime;
+import org.apache.hc.client5.http.cookie.BasicCookieStore;
+import org.apache.hc.client5.http.cookie.CookieStore;
+import org.apache.hc.client5.http.entity.compress.BasicCompressionDictionaryStore;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionaryStore;
+import org.apache.hc.client5.http.impl.Brotli4jRuntime;
+import org.apache.hc.client5.http.impl.ZstdRuntime;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.concurrent.CancellableDependency;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+class TestContentCompressionAsyncExecDictionary {
+
+ private static final String ORIGIN = "https://example.com";
+ private static final String AVAILABLE_DICTIONARY = "Available-Dictionary";
+ private static final String DICTIONARY_ID = "Dictionary-ID";
+ private static final String USE_AS_DICTIONARY = "Use-As-Dictionary";
+
+ @Mock
+ private AsyncExecChain execChain;
+ @Mock
+ private AsyncEntityProducer entityProducer;
+ @Mock
+ private AsyncExecCallback originalCb;
+ @Mock
+ private AsyncExecRuntime execRuntime;
+ @Mock
+ private CancellableDependency dependency;
+
+ private HttpClientContext context;
+ private AsyncExecChain.Scope scope;
+ private BasicCompressionDictionaryStore store;
+ private CookieStore cookieStore;
+ private ContentCompressionAsyncExec impl;
+
+ @BeforeEach
+ void init() {
+ MockitoAnnotations.openMocks(this);
+
+ final HttpHost target = new HttpHost("https", "example.com", 443);
+ final HttpRequest originalRequest = new BasicHttpRequest(Method.GET, "/");
+ context = HttpClientContext.create();
+ scope = new AsyncExecChain.Scope(
+ "test",
+ new HttpRoute(target),
+ originalRequest,
+ dependency,
+ context,
+ execRuntime,
+ null,
+ new AtomicInteger());
+
+ store = new BasicCompressionDictionaryStore();
+ cookieStore = new CompressionDictionaryCookieStore(new BasicCookieStore(), store);
+ context.setCookieStore(cookieStore);
+ impl = new ContentCompressionAsyncExec((CompressionDictionaryStore) store);
+ }
+
+ private AsyncExecCallback executeAndCapture(final HttpRequest request) throws Exception {
+ final ArgumentCaptor cap = ArgumentCaptor.forClass(AsyncExecCallback.class);
+ doNothing().when(execChain).proceed(eq(request), eq(entityProducer), eq(scope), cap.capture());
+ impl.execute(request, entityProducer, scope, execChain, originalCb);
+ return cap.getValue();
+ }
+
+ private static CompressionDictionary freshDictionary(final String match, final String id) {
+ final Instant now = Instant.now();
+ return new CompressionDictionary(
+ "dictionary-content".getBytes(StandardCharsets.UTF_8),
+ URI.create(ORIGIN),
+ match,
+ id,
+ now.minusSeconds(60),
+ now.plusSeconds(3600));
+ }
+
+ private static String expectedAvailableDictionary(final CompressionDictionary dictionary) {
+ return ":" + Base64.getEncoder().encodeToString(dictionary.getSha256()) + ":";
+ }
+
+ @Test
+ void testFreshDictionaryAddsAvailableDictionaryHeader() throws Exception {
+ final CompressionDictionary dictionary = freshDictionary("/*", "dict-1");
+ store.add(cookieStore, dictionary);
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ executeAndCapture(request);
+
+ assertTrue(request.containsHeader(AVAILABLE_DICTIONARY));
+ assertEquals(expectedAvailableDictionary(dictionary),
+ request.getFirstHeader(AVAILABLE_DICTIONARY).getValue());
+ }
+
+ @Test
+ void testFreshDictionaryAddsDictionaryIdHeaderWhenIdPresent() throws Exception {
+ store.add(cookieStore, freshDictionary("/*", "dict-1"));
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ executeAndCapture(request);
+
+ assertTrue(request.containsHeader(DICTIONARY_ID));
+ assertEquals("\"dict-1\"", request.getFirstHeader(DICTIONARY_ID).getValue());
+ }
+
+ @Test
+ void testDictionaryIdHeaderOmittedWhenIdEmpty() throws Exception {
+ store.add(cookieStore, freshDictionary("/*", ""));
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ executeAndCapture(request);
+
+ assertTrue(request.containsHeader(AVAILABLE_DICTIONARY));
+ assertFalse(request.containsHeader(DICTIONARY_ID));
+ }
+
+ @Test
+ void testAcceptEncodingIncludesDictionaryTokensWhenAvailable() throws Exception {
+ store.add(cookieStore, freshDictionary("/*", "dict-1"));
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ executeAndCapture(request);
+
+ assertTrue(request.containsHeader(HttpHeaders.ACCEPT_ENCODING));
+ final String acceptEncoding = request.getFirstHeader(HttpHeaders.ACCEPT_ENCODING).getValue();
+
+ // dcb / dcz tokens are only offered when the matching native runtime is present
+ assertEquals(Brotli4jRuntime.available(), tokenPresent(acceptEncoding, "dcb"));
+ assertEquals(ZstdRuntime.available(), tokenPresent(acceptEncoding, "dcz"));
+ }
+
+ private static boolean tokenPresent(final String headerValue, final String token) {
+ for (final String part : headerValue.split(",")) {
+ if (token.equalsIgnoreCase(part.trim())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Test
+ void testNoDictionaryWhenRequestAlreadyHasAvailableDictionary() throws Exception {
+ store.add(cookieStore, freshDictionary("/*", "dict-1"));
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ request.addHeader(AVAILABLE_DICTIONARY, ":preset:");
+ executeAndCapture(request);
+
+ // the exec must not add a second Available-Dictionary header nor a Dictionary-ID header
+ assertEquals(1, request.getHeaders(AVAILABLE_DICTIONARY).length);
+ assertEquals(":preset:", request.getFirstHeader(AVAILABLE_DICTIONARY).getValue());
+ assertFalse(request.containsHeader(DICTIONARY_ID));
+
+ final String acceptEncoding = request.getFirstHeader(HttpHeaders.ACCEPT_ENCODING).getValue();
+ assertFalse(tokenPresent(acceptEncoding, "dcb"));
+ assertFalse(tokenPresent(acceptEncoding, "dcz"));
+ }
+
+ @Test
+ void testNoDictionaryWhenRequestUriNotHttps() throws Exception {
+ store.add(cookieStore, freshDictionary("/*", "dict-1"));
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create("http://example.com/page.html"));
+ executeAndCapture(request);
+
+ assertFalse(request.containsHeader(AVAILABLE_DICTIONARY));
+ assertFalse(request.containsHeader(DICTIONARY_ID));
+
+ final String acceptEncoding = request.getFirstHeader(HttpHeaders.ACCEPT_ENCODING).getValue();
+ assertFalse(tokenPresent(acceptEncoding, "dcb"));
+ assertFalse(tokenPresent(acceptEncoding, "dcz"));
+ }
+
+ @Test
+ void testDcbContentEncodingRejectedWithoutNegotiatedDictionary() throws Exception {
+ // empty store -> no dictionary negotiated for the request
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ final AsyncExecCallback cb = executeAndCapture(request);
+
+ final HttpResponse rsp = new BasicHttpResponse(200, "OK");
+ final EntityDetails details = mock(EntityDetails.class);
+ when(details.getContentEncoding()).thenReturn("dcb");
+ when(originalCb.handleResponse(same(rsp), any(EntityDetails.class)))
+ .thenReturn(mock(AsyncDataConsumer.class));
+
+ assertThrows(HttpException.class, () -> cb.handleResponse(rsp, details));
+ }
+
+ @Test
+ void testDczContentEncodingRejectedWithoutNegotiatedDictionary() throws Exception {
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ final AsyncExecCallback cb = executeAndCapture(request);
+
+ final HttpResponse rsp = new BasicHttpResponse(200, "OK");
+ final EntityDetails details = mock(EntityDetails.class);
+ when(details.getContentEncoding()).thenReturn("dcz");
+ when(originalCb.handleResponse(same(rsp), any(EntityDetails.class)))
+ .thenReturn(mock(AsyncDataConsumer.class));
+
+ assertThrows(HttpException.class, () -> cb.handleResponse(rsp, details));
+ }
+
+ @Test
+ void testUseAsDictionaryWrapsDownstreamAndCapturesBody() throws Exception {
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ final AsyncExecCallback cb = executeAndCapture(request);
+
+ final HttpResponse rsp = new BasicHttpResponse(200, "OK");
+ rsp.addHeader(USE_AS_DICTIONARY, "match=\"/page.html\"");
+ rsp.addHeader(HttpHeaders.CACHE_CONTROL, "max-age=3600");
+
+ final EntityDetails details = mock(EntityDetails.class);
+ when(details.getContentEncoding()).thenReturn(null);
+
+ final DrainingConsumer downstream = new DrainingConsumer();
+ when(originalCb.handleResponse(same(rsp), any(EntityDetails.class))).thenReturn(downstream);
+
+ final AsyncDataConsumer wrapped = cb.handleResponse(rsp, details);
+
+ assertNotNull(wrapped);
+ assertTrue(wrapped instanceof DictionaryCapturingAsyncDataConsumer);
+
+ final byte[] body = "hello dictionary".getBytes(StandardCharsets.UTF_8);
+ wrapped.consume(ByteBuffer.wrap(body));
+ wrapped.streamEnd(null);
+
+ assertTrue(downstream.ended);
+
+ final List stored =
+ store.getByOrigin(cookieStore, URI.create(ORIGIN + "/page.html"));
+ assertEquals(1, stored.size());
+ assertEquals("/page.html", stored.get(0).getMatch());
+ assertArrayEquals(body, stored.get(0).getContent());
+ }
+
+ @Test
+ void testUseAsDictionaryCombinesMultipleFieldLines() throws Exception {
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ final AsyncExecCallback cb = executeAndCapture(request);
+ final HttpResponse rsp = new BasicHttpResponse(200, "OK");
+ rsp.addHeader(USE_AS_DICTIONARY, "match=\"/page.html\"");
+ rsp.addHeader(USE_AS_DICTIONARY, "id=\"split-field\"");
+ rsp.addHeader(HttpHeaders.CACHE_CONTROL, "max-age=3600");
+ final EntityDetails details = mock(EntityDetails.class);
+ when(details.getContentEncoding()).thenReturn(null);
+ final DrainingConsumer downstream = new DrainingConsumer();
+ when(originalCb.handleResponse(same(rsp), any(EntityDetails.class))).thenReturn(downstream);
+
+ final AsyncDataConsumer wrapped = cb.handleResponse(rsp, details);
+ wrapped.consume(ByteBuffer.wrap("dictionary".getBytes(StandardCharsets.UTF_8)));
+ wrapped.streamEnd(null);
+
+ final List stored =
+ store.getByOrigin(cookieStore, URI.create(ORIGIN + "/page.html"));
+ assertEquals(1, stored.size());
+ assertEquals("split-field", stored.get(0).getId());
+ }
+
+ @Test
+ void testUseAsDictionaryNotWrappedWithoutFreshnessDirective() throws Exception {
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ final AsyncExecCallback cb = executeAndCapture(request);
+
+ final HttpResponse rsp = new BasicHttpResponse(200, "OK");
+ // Use-As-Dictionary present but no max-age -> validUntil is null -> no capture wrapping
+ rsp.addHeader(USE_AS_DICTIONARY, "match=\"/page.html\"");
+
+ final EntityDetails details = mock(EntityDetails.class);
+ when(details.getContentEncoding()).thenReturn(null);
+
+ final DrainingConsumer downstream = new DrainingConsumer();
+ when(originalCb.handleResponse(same(rsp), any(EntityDetails.class))).thenReturn(downstream);
+
+ final AsyncDataConsumer wrapped = cb.handleResponse(rsp, details);
+
+ assertSame(downstream, wrapped);
+ }
+
+ @Test
+ void testDictionaryIsNotVisibleFromAnotherCookiePartition() throws Exception {
+ final CompressionDictionary dictionary = freshDictionary("/*", "dict-1");
+ store.add(cookieStore, dictionary);
+ context.setCookieStore(new CompressionDictionaryCookieStore(new BasicCookieStore(), store));
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ executeAndCapture(request);
+
+ assertFalse(request.containsHeader(AVAILABLE_DICTIONARY));
+ assertFalse(request.containsHeader(DICTIONARY_ID));
+ }
+
+ @Test
+ void testClearingCookiesClearsDictionaryPartition() throws Exception {
+ final CompressionDictionary dictionary = freshDictionary("/*", "dict-1");
+ store.add(cookieStore, dictionary);
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ executeAndCapture(request);
+
+ cookieStore.clear();
+
+ assertTrue(store.getByOrigin(cookieStore, URI.create(ORIGIN)).isEmpty());
+ }
+
+ @Test
+ void testUnmanagedCookieStoreDisablesDictionaryTransport() throws Exception {
+ final CookieStore unmanagedPartition = new BasicCookieStore();
+ context.setCookieStore(unmanagedPartition);
+ store.add(unmanagedPartition, freshDictionary("/*", "dict-1"));
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ executeAndCapture(request);
+
+ assertFalse(request.containsHeader(AVAILABLE_DICTIONARY));
+ assertFalse(request.containsHeader(DICTIONARY_ID));
+ }
+
+ @Test
+ void testCustomDcbDecoderIsUsed() throws Exception {
+ final LinkedHashMap> decoders = new LinkedHashMap<>();
+ final AsyncDataConsumer custom = mock(AsyncDataConsumer.class);
+ decoders.put("DCB", downstream -> custom);
+ impl = new ContentCompressionAsyncExec(decoders, store);
+ store.add(cookieStore, freshDictionary("/*", "dict-1"));
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, URI.create(ORIGIN + "/page.html"));
+ final AsyncExecCallback cb = executeAndCapture(request);
+ final HttpResponse rsp = new BasicHttpResponse(200, "OK");
+ final EntityDetails details = mock(EntityDetails.class);
+ when(details.getContentEncoding()).thenReturn("dcb");
+ when(originalCb.handleResponse(same(rsp), any(EntityDetails.class)))
+ .thenReturn(mock(AsyncDataConsumer.class));
+
+ assertSame(custom, cb.handleResponse(rsp, details));
+ }
+
+ private static final class DrainingConsumer implements AsyncDataConsumer {
+
+ private boolean ended;
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) {
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) {
+ src.position(src.limit());
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) {
+ ended = true;
+ }
+
+ @Override
+ public void releaseResources() {
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestDefaultCompressionDictionaryMatcher.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestDefaultCompressionDictionaryMatcher.java
new file mode 100644
index 0000000000..83a41685f3
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestDefaultCompressionDictionaryMatcher.java
@@ -0,0 +1,440 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class TestDefaultCompressionDictionaryMatcher {
+
+ private static final Instant NOW = Instant.parse("2026-08-21T12:00:00Z");
+
+ private DefaultCompressionDictionaryMatcher matcher;
+
+ @BeforeEach
+ void setUp() {
+ matcher = new DefaultCompressionDictionaryMatcher(Clock.fixed(NOW, ZoneOffset.UTC));
+ }
+
+ private static CompressionDictionary dictionary(
+ final String source,
+ final String match,
+ final Instant storedAt,
+ final Instant validUntil) {
+ return dictionary(source, match, Collections.emptyList(), storedAt, validUntil);
+ }
+
+ private static CompressionDictionary dictionary(
+ final String source,
+ final String match,
+ final List matchDest,
+ final Instant storedAt,
+ final Instant validUntil) {
+ return new CompressionDictionary(
+ new byte[] {1, 2, 3},
+ URI.create(source),
+ match,
+ matchDest,
+ "id",
+ "raw",
+ storedAt,
+ validUntil);
+ }
+
+ /**
+ * A dictionary rooted at https://example.com, valid for one hour from the fixed clock.
+ */
+ private static CompressionDictionary fresh(final String match) {
+ return dictionary(
+ "https://example.com/",
+ match,
+ NOW.minus(Duration.ofMinutes(10)),
+ NOW.plus(Duration.ofHours(1)));
+ }
+
+ private static Collection list(final CompressionDictionary... dictionaries) {
+ return Arrays.asList(dictionaries);
+ }
+
+ @Test
+ void nullRequestUriThrows() {
+ assertThrows(NullPointerException.class,
+ () -> matcher.match(null, Collections.emptyList()));
+ }
+
+ @Test
+ void nullDictionariesThrows() {
+ assertThrows(NullPointerException.class,
+ () -> matcher.match(URI.create("https://example.com/app"), null));
+ }
+
+ @Test
+ void nonHttpsRequestUriReturnsNull() {
+ final CompressionDictionary dictionary = fresh("/*");
+ assertNull(matcher.match(URI.create("http://example.com/app"), list(dictionary)));
+ }
+
+ @Test
+ void httpsSchemeIsCaseInsensitive() {
+ final CompressionDictionary dictionary = dictionary(
+ "HTTPS://example.com/",
+ "/app/*",
+ NOW.minus(Duration.ofMinutes(1)),
+ NOW.plus(Duration.ofHours(1)));
+ assertSame(dictionary,
+ matcher.match(URI.create("HTTPS://example.com/app/main.js"), list(dictionary)));
+ }
+
+ @Test
+ void nonHttpsDictionarySourceIsRejected() {
+ assertThrows(IllegalArgumentException.class, () -> dictionary(
+ "http://example.com/",
+ "/*",
+ NOW.minus(Duration.ofMinutes(1)),
+ NOW.plus(Duration.ofHours(1))));
+ }
+
+ @Test
+ void differentHostIsNotMatched() {
+ final CompressionDictionary dictionary = dictionary(
+ "https://other.com/",
+ "/*",
+ NOW.minus(Duration.ofMinutes(1)),
+ NOW.plus(Duration.ofHours(1)));
+ assertNull(matcher.match(URI.create("https://example.com/app"), list(dictionary)));
+ }
+
+ @Test
+ void differentPortIsNotMatched() {
+ final CompressionDictionary dictionary = dictionary(
+ "https://example.com:8443/",
+ "/*",
+ NOW.minus(Duration.ofMinutes(1)),
+ NOW.plus(Duration.ofHours(1)));
+ assertNull(matcher.match(URI.create("https://example.com/app"), list(dictionary)));
+ }
+
+ @Test
+ void defaultHttpsPortMatchesExplicit443() {
+ final CompressionDictionary dictionary = dictionary(
+ "https://example.com/",
+ "/*",
+ NOW.minus(Duration.ofMinutes(1)),
+ NOW.plus(Duration.ofHours(1)));
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com:443/app"), list(dictionary)));
+ }
+
+ @Test
+ void expiredDictionaryIsExcluded() {
+ final CompressionDictionary dictionary = dictionary(
+ "https://example.com/",
+ "/*",
+ NOW.minus(Duration.ofHours(2)),
+ NOW.minus(Duration.ofMinutes(1)));
+ assertNull(matcher.match(URI.create("https://example.com/app"), list(dictionary)));
+ }
+
+ @Test
+ void dictionaryExpiringAtTheClockInstantIsExcluded() {
+ // isFresh uses instant.isBefore(validUntil); validUntil == now is not fresh.
+ final CompressionDictionary dictionary = dictionary(
+ "https://example.com/",
+ "/*",
+ NOW.minus(Duration.ofHours(2)),
+ NOW);
+ assertNull(matcher.match(URI.create("https://example.com/app"), list(dictionary)));
+ }
+
+ @Test
+ void relativePatternUsesOutboundRequestAsBase() {
+ final CompressionDictionary dictionary = fresh("app/*");
+ assertNull(matcher.match(URI.create("https://example.com/app/x"), list(dictionary)));
+ }
+
+ @Test
+ void relativePatternWithParentSegmentUsesOutboundRequestAsBase() {
+ final CompressionDictionary dictionary = fresh("../app/*");
+ assertSame(dictionary, matcher.match(URI.create("https://example.com/app/x"), list(dictionary)));
+ }
+
+ @Test
+ void patternWithQueryMatchesQueryComponent() {
+ final CompressionDictionary dictionary = fresh("/app?x");
+ assertSame(dictionary, matcher.match(URI.create("https://example.com/app?x"), list(dictionary)));
+ }
+
+ @Test
+ void wildcardQuestionMarkIsAGroupModifier() {
+ final CompressionDictionary dictionary = fresh("https://example.com/*?foo");
+ assertNull(matcher.match(URI.create("https://example.com/?foo"), list(dictionary)));
+ }
+
+ @Test
+ void escapedQuestionMarkSeparatesTheSearchComponent() {
+ final CompressionDictionary dictionary = fresh("https://example.com/*\\?foo");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/?foo"), list(dictionary)));
+ }
+
+ @Test
+ void patternWithFragmentMatchesFragmentComponent() {
+ final CompressionDictionary dictionary = fresh("/app#x");
+ assertSame(dictionary, matcher.match(URI.create("https://example.com/app#x"), list(dictionary)));
+ }
+
+ @Test
+ void patternWithParenthesesNeverMatches() {
+ assertNull(matcher.match(URI.create("https://example.com/app"), list(fresh("/app("))));
+ assertNull(matcher.match(URI.create("https://example.com/app"), list(fresh("/app)"))));
+ }
+
+ @Test
+ void rfcPathPrefixExampleMatches() {
+ final CompressionDictionary dictionary = fresh("/product/*");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/product/123"), list(dictionary)));
+ }
+
+ @Test
+ void rfcVersionedDirectoriesExampleMatches() {
+ final CompressionDictionary dictionary = fresh("/app/*/main.js");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/app/v1/main.js"), list(dictionary)));
+ }
+
+ @Test
+ void percentEncodedPathMatchesAtHttpLevel() {
+ final CompressionDictionary dictionary = fresh("/d%C3%BCsseldorf");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/d%C3%BCsseldorf"), list(dictionary)));
+ }
+
+ @Test
+ void unbalancedPatternBracesNeverMatch() {
+ assertNull(matcher.match(URI.create("https://example.com/app"), list(fresh("/app{"))));
+ assertNull(matcher.match(URI.create("https://example.com/app"), list(fresh("/app}"))));
+ }
+
+ @Test
+ void optionalBraceGroupMatches() {
+ final CompressionDictionary dictionary = fresh("/app{/v1}?/*");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/app/v1/main.js"), list(dictionary)));
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/app/main.js"), list(dictionary)));
+ }
+
+ @Test
+ void patternWithColonNeverMatches() {
+ final CompressionDictionary dictionary = fresh("/app:x");
+ assertNull(matcher.match(URI.create("https://example.com/app"), list(dictionary)));
+ }
+
+ @Test
+ void namedGroupMatchesOnePathSegment() {
+ final CompressionDictionary dictionary = fresh("/app/:name");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/app/main.js"), list(dictionary)));
+ assertNull(matcher.match(URI.create("https://example.com/app/a/main.js"), list(dictionary)));
+ }
+
+ @Test
+ void absoluteSameOriginPatternMatches() {
+ final CompressionDictionary dictionary = fresh("https://example.com/app/*");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/app/main.js?version=1"), list(dictionary)));
+ }
+
+ @Test
+ void absoluteCrossOriginPatternNeverMatches() {
+ final CompressionDictionary dictionary = fresh("https://other.example/app/*");
+ assertNull(matcher.match(URI.create("https://example.com/app/main.js"), list(dictionary)));
+ }
+
+ @Test
+ void absoluteCrossOriginPatternIsValidButCannotMatchDictionaryOrigin() {
+ final CompressionDictionaryUrlPatternMatcher patternMatcher =
+ new DefaultCompressionDictionaryUrlPatternMatcher();
+ assertTrue(patternMatcher.isValid(
+ "https://other.example/app/*", URI.create("https://example.com/dictionary")));
+ assertNull(matcher.match(URI.create("https://example.com/app/main.js"),
+ list(fresh("https://other.example/app/*"))));
+ }
+
+ @Test
+ void patternedHostnameCanMatchWithinDictionaryOrigin() {
+ final CompressionDictionary dictionary = dictionary(
+ "https://www.example.com/",
+ "https://*.example.com/app/*",
+ NOW.minus(Duration.ofMinutes(1)),
+ NOW.plus(Duration.ofHours(1)));
+ assertSame(dictionary,
+ matcher.match(URI.create("https://www.example.com/app/main.js"), list(dictionary)));
+ }
+
+ @Test
+ void bracedAuthorityPatternMatchesWithinDictionaryOrigin() {
+ final CompressionDictionary dictionary = fresh(
+ "https://{sub.}?example{.com/}foo");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/foo"), list(dictionary)));
+ }
+
+ @Test
+ void escapedUserInfoDelimiterMatches() {
+ final CompressionDictionary dictionary = dictionary(
+ "https://foo:bar@example.com/dictionary",
+ "https://foo\\:bar@example.com",
+ NOW.minus(Duration.ofMinutes(1)),
+ NOW.plus(Duration.ofHours(1)));
+ assertSame(dictionary,
+ matcher.match(URI.create("https://foo:bar@example.com"), list(dictionary)));
+ }
+
+ @Test
+ void globPrefixMatchesSubPath() {
+ final CompressionDictionary dictionary = fresh("/app/*");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/app/main.js"), list(dictionary)));
+ }
+
+ @Test
+ void globPrefixDoesNotMatchDifferentPrefix() {
+ final CompressionDictionary dictionary = fresh("/app/*");
+ assertNull(matcher.match(URI.create("https://example.com/static/main.js"), list(dictionary)));
+ }
+
+ @Test
+ void rootGlobMatchesAnyPath() {
+ final CompressionDictionary dictionary = fresh("/*");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/anything/deep/path"), list(dictionary)));
+ }
+
+ @Test
+ void exactPatternMatchesExactPath() {
+ final CompressionDictionary dictionary = fresh("/app/main.js");
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/app/main.js"), list(dictionary)));
+ }
+
+ @Test
+ void exactPatternDoesNotMatchLongerPath() {
+ final CompressionDictionary dictionary = fresh("/app/main.js");
+ assertNull(matcher.match(URI.create("https://example.com/app/main.js.map"), list(dictionary)));
+ }
+
+ @Test
+ void emptyCollectionReturnsNull() {
+ assertNull(matcher.match(URI.create("https://example.com/app"),
+ Collections.emptyList()));
+ }
+
+ @Test
+ void longestMatchWins() {
+ final CompressionDictionary broad = fresh("/*");
+ final CompressionDictionary narrow = fresh("/app/*");
+ assertSame(narrow,
+ matcher.match(URI.create("https://example.com/app/main.js"), list(broad, narrow)));
+ // order independent
+ assertSame(narrow,
+ matcher.match(URI.create("https://example.com/app/main.js"), list(narrow, broad)));
+ }
+
+ @Test
+ void matchingDestinationTakesPrecedence() {
+ final CompressionDictionary destinationAgnostic = fresh("/app/*");
+ final CompressionDictionary destinationSpecific = dictionary(
+ "https://example.com/",
+ "/*",
+ Collections.singletonList("script"),
+ NOW.minus(Duration.ofMinutes(10)),
+ NOW.plus(Duration.ofHours(1)));
+ assertSame(destinationSpecific,
+ matcher.match(URI.create("https://example.com/app/main.js"), "script",
+ list(destinationAgnostic, destinationSpecific)));
+ }
+
+ @Test
+ void nonMatchingDestinationIsExcluded() {
+ final CompressionDictionary dictionary = dictionary(
+ "https://example.com/",
+ "/*",
+ Collections.singletonList("document"),
+ NOW.minus(Duration.ofMinutes(10)),
+ NOW.plus(Duration.ofHours(1)));
+ assertNull(matcher.match(
+ URI.create("https://example.com/app/main.js"), "script", list(dictionary)));
+ }
+
+ @Test
+ void unsupportedRequestDestinationsMatchAll() {
+ final CompressionDictionary dictionary = dictionary(
+ "https://example.com/",
+ "/*",
+ Collections.singletonList("document"),
+ NOW.minus(Duration.ofMinutes(10)),
+ NOW.plus(Duration.ofHours(1)));
+ assertSame(dictionary,
+ matcher.match(URI.create("https://example.com/app/main.js"), null, list(dictionary)));
+ }
+
+ @Test
+ void equalLengthLaterStoredAtWins() {
+ final CompressionDictionary older = dictionary(
+ "https://example.com/",
+ "/app/*",
+ NOW.minus(Duration.ofHours(2)),
+ NOW.plus(Duration.ofHours(1)));
+ final CompressionDictionary newer = dictionary(
+ "https://example.com/",
+ "/app/*",
+ NOW.minus(Duration.ofMinutes(5)),
+ NOW.plus(Duration.ofHours(1)));
+ assertSame(newer,
+ matcher.match(URI.create("https://example.com/app/x"), list(older, newer)));
+ assertSame(newer,
+ matcher.match(URI.create("https://example.com/app/x"), list(newer, older)));
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestDictionaryCapturingAsyncDataConsumer.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestDictionaryCapturingAsyncDataConsumer.java
new file mode 100644
index 0000000000..30b7c59671
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestDictionaryCapturingAsyncDataConsumer.java
@@ -0,0 +1,291 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.hc.client5.http.cookie.BasicCookieStore;
+import org.apache.hc.client5.http.cookie.CookieStore;
+import org.apache.hc.client5.http.entity.compress.BasicCompressionDictionaryStore;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionary;
+import org.apache.hc.client5.http.entity.compress.CompressionDictionaryStore;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class TestDictionaryCapturingAsyncDataConsumer {
+
+ private static final URI SOURCE = URI.create("https://example.com/resource");
+ private static final Instant STORED_AT = Instant.parse("2026-08-21T10:00:00Z");
+ private static final Instant VALID_UNTIL = Instant.parse("2026-08-22T10:00:00Z");
+
+ private RecordingDataConsumer downstream;
+ private CompressionDictionaryStore store;
+ private CookieStore partition;
+
+ @BeforeEach
+ void setUp() {
+ downstream = new RecordingDataConsumer();
+ store = new BasicCompressionDictionaryStore();
+ partition = new BasicCookieStore();
+ }
+
+ private static UseAsDictionary rawDirective() throws Exception {
+ return UseAsDictionary.parse("match=\"/resource\", id=\"dict-1\"");
+ }
+
+ private static UseAsDictionary unsupportedDirective() throws Exception {
+ return UseAsDictionary.parse("match=\"/resource\", id=\"dict-1\", type=other");
+ }
+
+ private DictionaryCapturingAsyncDataConsumer consumer(final int maxSize) throws Exception {
+ return new DictionaryCapturingAsyncDataConsumer(
+ downstream, store, partition, SOURCE, rawDirective(), STORED_AT, VALID_UNTIL, maxSize);
+ }
+
+ @Test
+ void testConstructorRejectsNullDownstream() throws Exception {
+ final UseAsDictionary directive = rawDirective();
+ assertThrows(NullPointerException.class, () -> new DictionaryCapturingAsyncDataConsumer(
+ null, store, partition, SOURCE, directive, STORED_AT, VALID_UNTIL, 1024));
+ }
+
+ @Test
+ void testConstructorRejectsNullStore() throws Exception {
+ final UseAsDictionary directive = rawDirective();
+ assertThrows(NullPointerException.class, () -> new DictionaryCapturingAsyncDataConsumer(
+ downstream, null, partition, SOURCE, directive, STORED_AT, VALID_UNTIL, 1024));
+ }
+
+ @Test
+ void testConstructorRejectsNullPartition() throws Exception {
+ final UseAsDictionary directive = rawDirective();
+ assertThrows(NullPointerException.class, () -> new DictionaryCapturingAsyncDataConsumer(
+ downstream, store, null, SOURCE, directive, STORED_AT, VALID_UNTIL, 1024));
+ }
+
+ @Test
+ void testConstructorRejectsNullSource() throws Exception {
+ final UseAsDictionary directive = rawDirective();
+ assertThrows(NullPointerException.class, () -> new DictionaryCapturingAsyncDataConsumer(
+ downstream, store, partition, null, directive, STORED_AT, VALID_UNTIL, 1024));
+ }
+
+ @Test
+ void testConstructorRejectsNullDirective() {
+ assertThrows(NullPointerException.class, () -> new DictionaryCapturingAsyncDataConsumer(
+ downstream, store, partition, SOURCE, null, STORED_AT, VALID_UNTIL, 1024));
+ }
+
+ @Test
+ void testConstructorRejectsNullStoredAt() throws Exception {
+ final UseAsDictionary directive = rawDirective();
+ assertThrows(NullPointerException.class, () -> new DictionaryCapturingAsyncDataConsumer(
+ downstream, store, partition, SOURCE, directive, null, VALID_UNTIL, 1024));
+ }
+
+ @Test
+ void testConstructorRejectsNullValidUntil() throws Exception {
+ final UseAsDictionary directive = rawDirective();
+ assertThrows(NullPointerException.class, () -> new DictionaryCapturingAsyncDataConsumer(
+ downstream, store, partition, SOURCE, directive, STORED_AT, null, 1024));
+ }
+
+ @Test
+ void testConstructorRejectsNonPositiveMaxSize() throws Exception {
+ final UseAsDictionary directive = rawDirective();
+ assertThrows(IllegalArgumentException.class, () -> new DictionaryCapturingAsyncDataConsumer(
+ downstream, store, partition, SOURCE, directive, STORED_AT, VALID_UNTIL, 0));
+ assertThrows(IllegalArgumentException.class, () -> new DictionaryCapturingAsyncDataConsumer(
+ downstream, store, partition, SOURCE, directive, STORED_AT, VALID_UNTIL, -1));
+ }
+
+ @Test
+ void testUpdateCapacityDelegates() throws Exception {
+ final AsyncDataConsumer spy = mock(AsyncDataConsumer.class);
+ final DictionaryCapturingAsyncDataConsumer mocked = new DictionaryCapturingAsyncDataConsumer(
+ spy, store, partition, SOURCE, rawDirective(), STORED_AT, VALID_UNTIL, 1024);
+ final CapacityChannel channel = mock(CapacityChannel.class);
+ mocked.updateCapacity(channel);
+ verify(spy).updateCapacity(channel);
+ }
+
+ @Test
+ void testConsumeDelegatesAndCaptures() throws Exception {
+ final DictionaryCapturingAsyncDataConsumer c = consumer(1024);
+ final byte[] payload = "hello world".getBytes(StandardCharsets.UTF_8);
+ final ByteBuffer src = ByteBuffer.wrap(payload);
+
+ c.consume(src);
+
+ assertArrayEquals(payload, downstream.consumedBytes());
+ assertEquals(0, src.remaining());
+ }
+
+ @Test
+ void testStreamEndStoresDictionaryMatchingInputs() throws Exception {
+ final DictionaryCapturingAsyncDataConsumer c = consumer(1024);
+ final byte[] part1 = "abc".getBytes(StandardCharsets.UTF_8);
+ final byte[] part2 = "defgh".getBytes(StandardCharsets.UTF_8);
+
+ c.consume(ByteBuffer.wrap(part1));
+ c.consume(ByteBuffer.wrap(part2));
+ c.streamEnd(Collections.emptyList());
+
+ assertTrue(downstream.streamEnded());
+
+ final List stored = store.getByOrigin(partition, SOURCE);
+ assertEquals(1, stored.size());
+ final CompressionDictionary dictionary = stored.get(0);
+ assertArrayEquals("abcdefgh".getBytes(StandardCharsets.UTF_8), dictionary.getContent());
+ assertEquals("/resource", dictionary.getMatch());
+ assertEquals("dict-1", dictionary.getId());
+ assertEquals(SOURCE, dictionary.getSource());
+ assertEquals(STORED_AT, dictionary.getStoredAt());
+ assertEquals(VALID_UNTIL, dictionary.getValidUntil());
+ }
+
+ @Test
+ void testExceedingMaxSizeDiscardsButDelegatesAllData() throws Exception {
+ final DictionaryCapturingAsyncDataConsumer c = consumer(4);
+ final byte[] part1 = "abc".getBytes(StandardCharsets.UTF_8);
+ final byte[] part2 = "defgh".getBytes(StandardCharsets.UTF_8);
+
+ c.consume(ByteBuffer.wrap(part1));
+ c.consume(ByteBuffer.wrap(part2));
+ c.streamEnd(Collections.emptyList());
+
+ assertTrue(store.getByOrigin(partition, SOURCE).isEmpty());
+ assertArrayEquals("abcdefgh".getBytes(StandardCharsets.UTF_8), downstream.consumedBytes());
+ assertTrue(downstream.streamEnded());
+ }
+
+ @Test
+ void testUnsupportedDirectiveStoresNothing() throws Exception {
+ final DictionaryCapturingAsyncDataConsumer c = new DictionaryCapturingAsyncDataConsumer(
+ downstream, store, partition, SOURCE, unsupportedDirective(), STORED_AT, VALID_UNTIL, 1024);
+ final byte[] payload = "payload".getBytes(StandardCharsets.UTF_8);
+
+ c.consume(ByteBuffer.wrap(payload));
+ c.streamEnd(Collections.emptyList());
+
+ assertTrue(store.getByOrigin(partition, SOURCE).isEmpty());
+ assertArrayEquals(payload, downstream.consumedBytes());
+ assertTrue(downstream.streamEnded());
+ }
+
+ @Test
+ void testDownstreamFailurePreventsStore() throws Exception {
+ final AsyncDataConsumer failing = mock(AsyncDataConsumer.class);
+ doThrow(new IOException("failure")).when(failing).streamEnd(anyList());
+ final DictionaryCapturingAsyncDataConsumer c = new DictionaryCapturingAsyncDataConsumer(
+ failing, store, partition, SOURCE, rawDirective(), STORED_AT, VALID_UNTIL, 1024);
+ c.consume(ByteBuffer.wrap("payload".getBytes(StandardCharsets.UTF_8)));
+
+ assertThrows(IOException.class, () -> c.streamEnd(Collections.emptyList()));
+ assertTrue(store.getByOrigin(partition, SOURCE).isEmpty());
+ }
+
+ @Test
+ void testReleaseResourcesDelegatesAndPreventsStore() throws Exception {
+ final DictionaryCapturingAsyncDataConsumer c = consumer(1024);
+ final byte[] payload = "payload".getBytes(StandardCharsets.UTF_8);
+
+ c.consume(ByteBuffer.wrap(payload));
+ c.releaseResources();
+ c.streamEnd(Collections.emptyList());
+
+ assertTrue(downstream.released());
+ assertTrue(store.getByOrigin(partition, SOURCE).isEmpty());
+ }
+
+ /**
+ * Fake downstream that drains (advances the position of) the buffer it is given, so the
+ * wrapper's {@code src.position()} delta reflects the bytes actually consumed.
+ */
+ private static final class RecordingDataConsumer implements AsyncDataConsumer {
+
+ private final ByteArrayOutputStream recorded = new ByteArrayOutputStream();
+ private final List capacityChannels = new ArrayList<>();
+ private boolean streamEnded;
+ private boolean released;
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ capacityChannels.add(capacityChannel);
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ while (src.hasRemaining()) {
+ recorded.write(src.get());
+ }
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ streamEnded = true;
+ }
+
+ @Override
+ public void releaseResources() {
+ released = true;
+ }
+
+ byte[] consumedBytes() {
+ return recorded.toByteArray();
+ }
+
+ boolean streamEnded() {
+ return streamEnded;
+ }
+
+ boolean released() {
+ return released;
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestUseAsDictionary.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestUseAsDictionary.java
new file mode 100644
index 0000000000..759a623ac1
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestUseAsDictionary.java
@@ -0,0 +1,260 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hc.core5.http.ParseException;
+import org.junit.jupiter.api.Test;
+
+class TestUseAsDictionary {
+
+ private static String repeat(final char ch, final int count) {
+ final StringBuilder builder = new StringBuilder(count);
+ for (int i = 0; i < count; i++) {
+ builder.append(ch);
+ }
+ return builder.toString();
+ }
+
+ @Test
+ void parseWellFormedValue() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"/app/*\", id=\"v1\", type=raw");
+ assertEquals("/app/*", dictionary.getMatch());
+ assertEquals("v1", dictionary.getId());
+ assertTrue(dictionary.isSupported());
+ }
+
+ @Test
+ void defaultTypeIsRawWhenAbsent() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"/app/*\", id=\"v1\"");
+ assertEquals("/app/*", dictionary.getMatch());
+ assertEquals("v1", dictionary.getId());
+ assertTrue(dictionary.isSupported());
+ }
+
+ @Test
+ void typeOtherIsNotSupported() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"/app/*\", type=other");
+ assertEquals("/app/*", dictionary.getMatch());
+ assertFalse(dictionary.isSupported());
+ }
+
+ @Test
+ void typeTokenIsCaseSensitive() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"/app/*\", type=RAW");
+ assertFalse(dictionary.isSupported());
+ }
+
+ @Test
+ void idDefaultsToEmpty() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"/app/*\"");
+ assertEquals("", dictionary.getId());
+ assertTrue(dictionary.isSupported());
+ }
+
+ @Test
+ void missingMatchThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("id=\"v1\", type=raw"));
+ }
+
+ @Test
+ void emptyMatchStringThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=\"\""));
+ }
+
+ @Test
+ void nullValueThrows() {
+ assertThrows(NullPointerException.class, () -> UseAsDictionary.parse(null));
+ }
+
+ @Test
+ void blankValueThrows() {
+ assertThrows(IllegalArgumentException.class, () -> UseAsDictionary.parse(" "));
+ }
+
+ @Test
+ void emptyValueThrows() {
+ assertThrows(IllegalArgumentException.class, () -> UseAsDictionary.parse(""));
+ }
+
+ @Test
+ void idExactly1024CharactersIsAccepted() throws ParseException {
+ final String id = repeat('a', 1024);
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"/x\", id=\"" + id + "\"");
+ assertEquals(1024, dictionary.getId().length());
+ }
+
+ @Test
+ void idExceeding1024CharactersThrows() {
+ final String id = repeat('a', 1025);
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=\"/x\", id=\"" + id + "\""));
+ }
+
+ @Test
+ void escapedQuoteInsideStringIsUnescaped() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"a\\\"b\"");
+ assertEquals("a\"b", dictionary.getMatch());
+ }
+
+ @Test
+ void escapedBackslashInsideStringIsUnescaped() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"a\\\\b\"");
+ assertEquals("a\\b", dictionary.getMatch());
+ }
+
+ @Test
+ void invalidEscapeSequenceThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=\"a\\b\""));
+ }
+
+ @Test
+ void controlCharacterInsideStringThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=\"a\tb\""));
+ }
+
+ @Test
+ void nonAsciiCharacterInsideStringThrows() {
+ final String value = "match=\"" + 'é' + "\"";
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse(value));
+ }
+
+ @Test
+ void unquotedMatchThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=raw"));
+ }
+
+ @Test
+ void unbalancedQuoteThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=\"abc"));
+ }
+
+ @Test
+ void unbalancedOpenParenThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=\"/x\", dummy=(a"));
+ }
+
+ @Test
+ void unbalancedCloseParenThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=\"/x\", dummy=a)"));
+ }
+
+ @Test
+ void commaInsideQuotesIsNotSeparator() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"a,b\", id=\"c\"");
+ assertEquals("a,b", dictionary.getMatch());
+ assertEquals("c", dictionary.getId());
+ }
+
+ @Test
+ void commaInsideInnerListIsInvalidStructuredFieldSyntax() {
+ assertThrows(ParseException.class,
+ () -> UseAsDictionary.parse("dummy=(a, b), match=\"/x\", type=raw"));
+ }
+
+ @Test
+ void matchDestParsesInnerListOfStrings() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse(
+ "match=\"/x\", match-dest=(\"document\" \"script\")");
+ assertEquals(2, dictionary.getMatchDest().size());
+ assertEquals("document", dictionary.getMatchDest().get(0));
+ assertEquals("script", dictionary.getMatchDest().get(1));
+ }
+
+ @Test
+ void memberWithoutEqualsIsIgnored() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("foo, match=\"/x\"");
+ assertEquals("/x", dictionary.getMatch());
+ }
+
+ @Test
+ void parametersAfterSemicolonAreIgnored() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"/x\", type=raw;foo=bar");
+ assertTrue(dictionary.isSupported());
+ }
+
+ @Test
+ void spaceAfterParameterSemicolonIsAccepted() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"/x\"; foo=bar");
+ assertEquals("/x", dictionary.getMatch());
+ }
+
+ @Test
+ void parametersAfterSemicolonAreIgnoredForUnsupportedType() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse("match=\"/x\", type=other;foo=bar");
+ assertFalse(dictionary.isSupported());
+ }
+
+ @Test
+ void emptyTypeTokenThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=\"/x\", type="));
+ }
+
+ @Test
+ void invalidTypeTokenCharacterThrows() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("match=\"/x\", type=a b"));
+ }
+
+ @Test
+ void integerLongerThanFifteenDigitsInvalidatesWholeField() {
+ assertThrows(ParseException.class,
+ () -> UseAsDictionary.parse("match=\"/x\", extension=1234567890123456"));
+ }
+
+ @Test
+ void decimalIntegerPartLongerThanTwelveDigitsInvalidatesWholeField() {
+ assertThrows(ParseException.class,
+ () -> UseAsDictionary.parse("match=\"/x\", extension=1234567890123.1"));
+ }
+
+ @Test
+ void dateLongerThanFifteenDigitsInvalidatesWholeField() {
+ assertThrows(ParseException.class,
+ () -> UseAsDictionary.parse("match=\"/x\", extension=@1234567890123456"));
+ }
+
+ @Test
+ void malformedUtf8DisplayStringInvalidatesWholeField() {
+ assertThrows(ParseException.class,
+ () -> UseAsDictionary.parse("match=\"/x\", extension=%\"%ff\""));
+ }
+
+ @Test
+ void validUtf8DisplayStringIsAcceptedAsAnExtension() throws ParseException {
+ final UseAsDictionary dictionary = UseAsDictionary.parse(
+ "match=\"/x\", extension=%\"caf%c3%a9\"");
+ assertEquals("/x", dictionary.getMatch());
+ }
+
+ @Test
+ void leadingTabIsNotAccepted() {
+ assertThrows(ParseException.class, () -> UseAsDictionary.parse("\tmatch=\"/x\""));
+ }
+}