diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/H2PseudoRequestHeaders.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/H2PseudoRequestHeaders.java index 0be8efc6d..7ac3b5ac7 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/H2PseudoRequestHeaders.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/H2PseudoRequestHeaders.java @@ -38,5 +38,9 @@ public final class H2PseudoRequestHeaders { public static final String SCHEME = ":scheme"; public static final String AUTHORITY = ":authority"; public static final String PATH = ":path"; + /** + * RFC 8441 extended CONNECT pseudo-header. + */ + public static final String PROTOCOL = ":protocol"; } diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/DefaultH2RequestConverter.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/DefaultH2RequestConverter.java index 253b15726..23b3d3192 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/DefaultH2RequestConverter.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/DefaultH2RequestConverter.java @@ -61,6 +61,7 @@ public HttpRequest convert(final List
headers) throws HttpException { String scheme = null; String authority = null; String path = null; + String protocol = null; final List
messageHeaders = new ArrayList<>(); for (int i = 0; i < headers.size(); i++) { @@ -98,6 +99,12 @@ public HttpRequest convert(final List
headers) throws HttpException { case H2PseudoRequestHeaders.AUTHORITY: authority = value; break; + case H2PseudoRequestHeaders.PROTOCOL: + if (protocol != null) { + throw new ProtocolException("Multiple '%s' request headers are illegal", name); + } + protocol = value; + break; default: throw new ProtocolException("Unsupported request header '%s'", name); } @@ -118,11 +125,21 @@ public HttpRequest convert(final List
headers) throws HttpException { if (authority == null) { throw new ProtocolException("Header '%s' is mandatory for CONNECT request", H2PseudoRequestHeaders.AUTHORITY); } - if (scheme != null) { - throw new ProtocolException("Header '%s' must not be set for CONNECT request", H2PseudoRequestHeaders.SCHEME); - } - if (path != null) { - throw new ProtocolException("Header '%s' must not be set for CONNECT request", H2PseudoRequestHeaders.PATH); + if (protocol != null) { + if (scheme == null) { + throw new ProtocolException("Header '%s' is mandatory for extended CONNECT", H2PseudoRequestHeaders.SCHEME); + } + if (path == null) { + throw new ProtocolException("Header '%s' is mandatory for extended CONNECT", H2PseudoRequestHeaders.PATH); + } + validatePathPseudoHeader(method, scheme, path); + } else { + if (scheme != null) { + throw new ProtocolException("Header '%s' must not be set for CONNECT request", H2PseudoRequestHeaders.SCHEME); + } + if (path != null) { + throw new ProtocolException("Header '%s' must not be set for CONNECT request", H2PseudoRequestHeaders.PATH); + } } } else { if (scheme == null) { @@ -143,6 +160,9 @@ public HttpRequest convert(final List
headers) throws HttpException { throw new ProtocolException(ex.getMessage(), ex); } httpRequest.setPath(path); + if (protocol != null) { + httpRequest.addHeader(new BasicHeader(H2PseudoRequestHeaders.PROTOCOL, protocol)); + } for (int i = 0; i < messageHeaders.size(); i++) { httpRequest.addHeader(messageHeaders.get(i)); } @@ -155,12 +175,26 @@ public List
convert(final HttpRequest message) throws HttpException { throw new ProtocolException("Request method is empty"); } final boolean optionMethod = Method.CONNECT.name().equalsIgnoreCase(message.getMethod()); + final Header protocolHeader = message.getFirstHeader(H2PseudoRequestHeaders.PROTOCOL); + final String protocol = protocolHeader != null ? protocolHeader.getValue() : null; + if (protocol != null && !optionMethod) { + throw new ProtocolException("Header name '%s' is invalid", H2PseudoRequestHeaders.PROTOCOL); + } if (optionMethod) { if (message.getAuthority() == null) { throw new ProtocolException("CONNECT request authority is not set"); } - if (message.getPath() != null) { - throw new ProtocolException("CONNECT request path must be null"); + if (protocol != null) { + if (TextUtils.isBlank(message.getScheme())) { + throw new ProtocolException("CONNECT request scheme is not set"); + } + if (TextUtils.isBlank(message.getPath())) { + throw new ProtocolException("CONNECT request path is not set"); + } + } else { + if (message.getPath() != null) { + throw new ProtocolException("CONNECT request path must be null"); + } } } else { if (TextUtils.isBlank(message.getScheme())) { @@ -173,7 +207,14 @@ public List
convert(final HttpRequest message) throws HttpException { final List
headers = new ArrayList<>(); headers.add(new BasicHeader(H2PseudoRequestHeaders.METHOD, message.getMethod(), false)); if (optionMethod) { - headers.add(new BasicHeader(H2PseudoRequestHeaders.AUTHORITY, message.getAuthority(), false)); + if (protocol != null) { + headers.add(new BasicHeader(H2PseudoRequestHeaders.PROTOCOL, protocol, false)); + headers.add(new BasicHeader(H2PseudoRequestHeaders.SCHEME, message.getScheme(), false)); + headers.add(new BasicHeader(H2PseudoRequestHeaders.AUTHORITY, message.getAuthority(), false)); + headers.add(new BasicHeader(H2PseudoRequestHeaders.PATH, message.getPath(), false)); + } else { + headers.add(new BasicHeader(H2PseudoRequestHeaders.AUTHORITY, message.getAuthority(), false)); + } } else { headers.add(new BasicHeader(H2PseudoRequestHeaders.SCHEME, message.getScheme(), false)); if (message.getAuthority() != null) { @@ -186,6 +227,12 @@ public List
convert(final HttpRequest message) throws HttpException { final Header header = it.next(); final String name = header.getName(); final String value = header.getValue(); + if (name.startsWith(":")) { + if (optionMethod && H2PseudoRequestHeaders.PROTOCOL.equals(name)) { + continue; + } + throw new ProtocolException("Header name '%s' is invalid", name); + } if (!FieldValidationSupport.isNameValid(name)) { throw new ProtocolException("Header name '%s' is invalid", name); } diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestExtendedConnectRequestConverter.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestExtendedConnectRequestConverter.java new file mode 100644 index 000000000..1e2cfa03b --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestExtendedConnectRequestConverter.java @@ -0,0 +1,80 @@ +/* + * ==================================================================== + * 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.core5.http2.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http2.H2PseudoRequestHeaders; +import org.apache.hc.core5.net.URIAuthority; +import org.junit.jupiter.api.Test; + +class TestExtendedConnectRequestConverter { + + @Test + void parsesExtendedConnect() throws Exception { + final List
headers = new ArrayList<>(); + headers.add(new BasicHeader(H2PseudoRequestHeaders.METHOD, Method.CONNECT.name(), false)); + headers.add(new BasicHeader(H2PseudoRequestHeaders.PROTOCOL, "websocket", false)); + headers.add(new BasicHeader(H2PseudoRequestHeaders.SCHEME, "https", false)); + headers.add(new BasicHeader(H2PseudoRequestHeaders.AUTHORITY, "example.com", false)); + headers.add(new BasicHeader(H2PseudoRequestHeaders.PATH, "/echo", false)); + + final DefaultH2RequestConverter converter = new DefaultH2RequestConverter(); + final HttpRequest request = converter.convert(headers); + assertNotNull(request); + assertEquals(Method.CONNECT.name(), request.getMethod()); + assertEquals("/echo", request.getPath()); + assertEquals("websocket", request.getFirstHeader(H2PseudoRequestHeaders.PROTOCOL).getValue()); + } + + @Test + void emitsProtocolPseudoHeader() throws Exception { + final DefaultH2RequestConverter converter = new DefaultH2RequestConverter(); + final HttpRequest request = new org.apache.hc.core5.http.message.BasicHttpRequest(Method.CONNECT.name(), "/echo"); + request.setScheme("https"); + request.setAuthority(new URIAuthority("example.com")); + request.setPath("/echo"); + request.addHeader(H2PseudoRequestHeaders.PROTOCOL, "websocket"); + final List
headers = converter.convert(request); + boolean found = false; + for (final Header header : headers) { + if (H2PseudoRequestHeaders.PROTOCOL.equals(header.getName())) { + found = true; + break; + } + } + assertEquals(true, found); + } +} diff --git a/httpcore5-websocket/pom.xml b/httpcore5-websocket/pom.xml new file mode 100644 index 000000000..217b79521 --- /dev/null +++ b/httpcore5-websocket/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + org.apache.httpcomponents.core5 + httpcore5-parent + 5.5-alpha1-SNAPSHOT + + + httpcore5-websocket + Apache HttpComponents Core WebSocket + 2005 + Apache HttpComponents WebSocket server support built on HttpCore + + + org.apache.httpcomponents.core5.websocket + + + + + org.apache.httpcomponents.core5 + httpcore5 + + + org.apache.httpcomponents.core5 + httpcore5-h2 + + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.platform + junit-platform-launcher + test + + + org.hamcrest + hamcrest + test + + + + diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtension.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtension.java new file mode 100644 index 000000000..df412369a --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtension.java @@ -0,0 +1,180 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.zip.Deflater; +import java.util.zip.Inflater; + +public final class PerMessageDeflateExtension implements WebSocketExtension { + + private static final byte[] TAIL = new byte[]{0x00, 0x00, (byte) 0xFF, (byte) 0xFF}; + private static final int MIN_WINDOW_BITS = 8; + private static final int MAX_WINDOW_BITS = 15; + + private final boolean serverNoContextTakeover; + private final boolean clientNoContextTakeover; + private final Integer clientMaxWindowBits; + private final Integer serverMaxWindowBits; + + private final Inflater inflater = new Inflater(true); + private final Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true); + private boolean decodingMessage; + + public PerMessageDeflateExtension() { + this(false, false, MAX_WINDOW_BITS, MAX_WINDOW_BITS); + } + + PerMessageDeflateExtension( + final boolean serverNoContextTakeover, + final boolean clientNoContextTakeover, + final Integer clientMaxWindowBits, + final Integer serverMaxWindowBits) { + this.serverNoContextTakeover = serverNoContextTakeover; + this.clientNoContextTakeover = clientNoContextTakeover; + this.clientMaxWindowBits = clientMaxWindowBits; + this.serverMaxWindowBits = serverMaxWindowBits; + this.decodingMessage = false; + } + + @Override + public String getName() { + return "permessage-deflate"; + } + + @Override + public boolean usesRsv1() { + return true; + } + + @Override + public ByteBuffer decode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) throws WebSocketException { + if (!isDataFrame(type) && type != WebSocketFrameType.CONTINUATION) { + throw new WebSocketException("Unsupported frame type for permessage-deflate: " + type); + } + if (type == WebSocketFrameType.CONTINUATION && !decodingMessage) { + throw new WebSocketException("Unexpected continuation frame for permessage-deflate"); + } + final byte[] input = toByteArray(payload); + final byte[] withTail; + if (fin) { + withTail = new byte[input.length + TAIL.length]; + System.arraycopy(input, 0, withTail, 0, input.length); + System.arraycopy(TAIL, 0, withTail, input.length, TAIL.length); + } else { + withTail = input; + } + inflater.setInput(withTail); + final ByteArrayOutputStream out = new ByteArrayOutputStream(input.length); + final byte[] buffer = new byte[8192]; + try { + while (!inflater.needsInput()) { + final int count = inflater.inflate(buffer); + if (count == 0 && inflater.needsInput()) { + break; + } + out.write(buffer, 0, count); + } + } catch (final Exception ex) { + throw new WebSocketException("Unable to inflate payload", ex); + } + if (fin) { + decodingMessage = false; + if (clientNoContextTakeover) { + inflater.reset(); + } + } else { + decodingMessage = true; + } + return ByteBuffer.wrap(out.toByteArray()); + } + + @Override + public ByteBuffer encode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) throws WebSocketException { + if (!isDataFrame(type) && type != WebSocketFrameType.CONTINUATION) { + throw new WebSocketException("Unsupported frame type for permessage-deflate: " + type); + } + final byte[] input = toByteArray(payload); + deflater.setInput(input); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, input.length / 2)); + final byte[] buffer = new byte[8192]; + while (!deflater.needsInput()) { + final int count = deflater.deflate(buffer, 0, buffer.length, Deflater.SYNC_FLUSH); + if (count > 0) { + out.write(buffer, 0, count); + } else { + break; + } + } + final byte[] data = out.toByteArray(); + final ByteBuffer encoded; + if (data.length >= 4) { + encoded = ByteBuffer.wrap(data, 0, data.length - 4); + } else { + encoded = ByteBuffer.wrap(data); + } + if (fin && serverNoContextTakeover) { + deflater.reset(); + } + return encoded; + } + + @Override + public WebSocketExtensionData getResponseData() { + final Map params = new java.util.LinkedHashMap<>(); + if (serverNoContextTakeover) { + params.put("server_no_context_takeover", null); + } + if (clientNoContextTakeover) { + params.put("client_no_context_takeover", null); + } + if (clientMaxWindowBits != null) { + params.put("client_max_window_bits", Integer.toString(clientMaxWindowBits)); + } + if (serverMaxWindowBits != null) { + params.put("server_max_window_bits", Integer.toString(serverMaxWindowBits)); + } + return new WebSocketExtensionData(getName(), params); + } + + private static boolean isDataFrame(final WebSocketFrameType type) { + return type == WebSocketFrameType.TEXT || type == WebSocketFrameType.BINARY; + } + + static boolean isValidWindowBits(final Integer bits) { + return bits == null || bits >= MIN_WINDOW_BITS && bits <= MAX_WINDOW_BITS; + } + + private static byte[] toByteArray(final ByteBuffer payload) { + final ByteBuffer buffer = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0); + final byte[] data = new byte[buffer.remaining()]; + buffer.get(data); + return data; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactory.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactory.java new file mode 100644 index 000000000..6166efe84 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactory.java @@ -0,0 +1,96 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +/** + * Factory for {@code permessage-deflate} extensions (RFC 7692). + * + *

Note: the JDK {@link java.util.zip.Deflater} / {@link java.util.zip.Inflater} + * only support a 15-bit window size. This factory therefore accepts + * {@code client_max_window_bits} / {@code server_max_window_bits} only when + * they are either absent or explicitly set to {@code 15}. Other values are + * rejected during negotiation.

+ */ +public final class PerMessageDeflateExtensionFactory implements WebSocketExtensionFactory { + + @Override + public String getName() { + return "permessage-deflate"; + } + + @Override + public WebSocketExtension create(final WebSocketExtensionData request, final boolean server) { + if (request == null) { + return null; + } + final String name = request.getName(); + if (!"permessage-deflate".equals(name)) { + return null; + } + final boolean serverNoContextTakeover = request.getParameters().containsKey("server_no_context_takeover"); + final boolean clientNoContextTakeover = request.getParameters().containsKey("client_no_context_takeover"); + final boolean clientMaxBitsPresent = request.getParameters().containsKey("client_max_window_bits"); + final boolean serverMaxBitsPresent = request.getParameters().containsKey("server_max_window_bits"); + Integer clientMaxWindowBits = parseWindowBits(request.getParameters().get("client_max_window_bits")); + Integer serverMaxWindowBits = parseWindowBits(request.getParameters().get("server_max_window_bits")); + if (clientMaxBitsPresent && clientMaxWindowBits == null) { + clientMaxWindowBits = 15; + } + if (serverMaxBitsPresent && serverMaxWindowBits == null) { + serverMaxWindowBits = 15; + } + + if (!PerMessageDeflateExtension.isValidWindowBits(clientMaxWindowBits) + || !PerMessageDeflateExtension.isValidWindowBits(serverMaxWindowBits)) { + return null; + } + // JDK Deflater/Inflater only supports 15-bit window size. + if (!isSupportedWindowBits(clientMaxWindowBits) || !isSupportedWindowBits(serverMaxWindowBits)) { + return null; + } + return new PerMessageDeflateExtension( + serverNoContextTakeover, + clientNoContextTakeover, + clientMaxWindowBits, + serverMaxWindowBits); + } + + private static Integer parseWindowBits(final String value) { + if (value == null || value.isEmpty()) { + return null; + } + try { + return Integer.parseInt(value); + } catch (final NumberFormatException ignore) { + return null; + } + } + + private static boolean isSupportedWindowBits(final Integer bits) { + return bits == null || bits == 15; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketCloseStatus.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketCloseStatus.java new file mode 100644 index 000000000..e1c5677de --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketCloseStatus.java @@ -0,0 +1,55 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +public enum WebSocketCloseStatus { + + NORMAL(1000), + GOING_AWAY(1001), + PROTOCOL_ERROR(1002), + UNSUPPORTED_DATA(1003), + NO_STATUS_RECEIVED(1005), + ABNORMAL_CLOSURE(1006), + INVALID_PAYLOAD(1007), + POLICY_VIOLATION(1008), + MESSAGE_TOO_BIG(1009), + MANDATORY_EXTENSION(1010), + INTERNAL_ERROR(1011), + SERVICE_RESTART(1012), + TRY_AGAIN_LATER(1013), + BAD_GATEWAY(1014); + + private final int code; + + WebSocketCloseStatus(final int code) { + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConfig.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConfig.java new file mode 100644 index 000000000..589275e48 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConfig.java @@ -0,0 +1,74 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import org.apache.hc.core5.util.Args; + +public final class WebSocketConfig { + + public static final WebSocketConfig DEFAULT = WebSocketConfig.custom().build(); + + private final int maxFramePayloadSize; + private final int maxMessageSize; + + private WebSocketConfig(final Builder builder) { + this.maxFramePayloadSize = builder.maxFramePayloadSize; + this.maxMessageSize = builder.maxMessageSize; + } + + public int getMaxFramePayloadSize() { + return maxFramePayloadSize; + } + + public int getMaxMessageSize() { + return maxMessageSize; + } + + public static Builder custom() { + return new Builder(); + } + + public static final class Builder { + + private int maxFramePayloadSize = 16 * 1024 * 1024; + private int maxMessageSize = 64 * 1024 * 1024; + + public Builder setMaxFramePayloadSize(final int maxFramePayloadSize) { + this.maxFramePayloadSize = Args.positive(maxFramePayloadSize, "Max frame payload size"); + return this; + } + + public Builder setMaxMessageSize(final int maxMessageSize) { + this.maxMessageSize = Args.positive(maxMessageSize, "Max message size"); + return this; + } + + public WebSocketConfig build() { + return new WebSocketConfig(this); + } + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConstants.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConstants.java new file mode 100644 index 000000000..e62ec0f12 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConstants.java @@ -0,0 +1,43 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +public final class WebSocketConstants { + + public static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + public static final String SEC_WEBSOCKET_KEY = "Sec-WebSocket-Key"; + public static final String SEC_WEBSOCKET_VERSION = "Sec-WebSocket-Version"; + public static final String SEC_WEBSOCKET_ACCEPT = "Sec-WebSocket-Accept"; + public static final String SEC_WEBSOCKET_PROTOCOL = "Sec-WebSocket-Protocol"; + public static final String SEC_WEBSOCKET_EXTENSIONS = "Sec-WebSocket-Extensions"; + public static final String SEC_WEBSOCKET_VERSION_LOWER = "sec-websocket-version"; + public static final String SEC_WEBSOCKET_PROTOCOL_LOWER = "sec-websocket-protocol"; + public static final String SEC_WEBSOCKET_EXTENSIONS_LOWER = "sec-websocket-extensions"; + + private WebSocketConstants() { + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketException.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketException.java new file mode 100644 index 000000000..4641e29f4 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketException.java @@ -0,0 +1,42 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.io.IOException; + +public class WebSocketException extends IOException { + + private static final long serialVersionUID = 1L; + + public WebSocketException(final String message) { + super(message); + } + + public WebSocketException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtension.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtension.java new file mode 100644 index 000000000..73988651c --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtension.java @@ -0,0 +1,64 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.nio.ByteBuffer; + +public interface WebSocketExtension { + + String getName(); + + default boolean usesRsv1() { + return false; + } + + default boolean usesRsv2() { + return false; + } + + default boolean usesRsv3() { + return false; + } + + default ByteBuffer decode( + final WebSocketFrameType type, + final boolean fin, + final ByteBuffer payload) throws WebSocketException { + return payload; + } + + default ByteBuffer encode( + final WebSocketFrameType type, + final boolean fin, + final ByteBuffer payload) throws WebSocketException { + return payload; + } + + default WebSocketExtensionData getResponseData() { + return new WebSocketExtensionData(getName(), null); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionData.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionData.java new file mode 100644 index 000000000..082fea84d --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionData.java @@ -0,0 +1,68 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TextUtils; + +public final class WebSocketExtensionData { + + private final String name; + private final Map parameters; + + public WebSocketExtensionData(final String name, final Map parameters) { + this.name = Args.notBlank(name, "Extension name"); + if (parameters != null && !parameters.isEmpty()) { + this.parameters = new LinkedHashMap<>(parameters); + } else { + this.parameters = Collections.emptyMap(); + } + } + + public String getName() { + return name; + } + + public Map getParameters() { + return parameters; + } + + public String format() { + final StringBuilder buffer = new StringBuilder(name); + for (final Map.Entry entry : parameters.entrySet()) { + buffer.append("; ").append(entry.getKey()); + if (!TextUtils.isBlank(entry.getValue())) { + buffer.append('=').append(entry.getValue()); + } + } + return buffer.toString(); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionFactory.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionFactory.java new file mode 100644 index 000000000..ebbb1712a --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionFactory.java @@ -0,0 +1,34 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +public interface WebSocketExtensionFactory { + + String getName(); + + WebSocketExtension create(WebSocketExtensionData request, boolean server) throws WebSocketException; +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiation.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiation.java new file mode 100644 index 000000000..4acf9779c --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiation.java @@ -0,0 +1,61 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.util.Collections; +import java.util.List; +import java.util.StringJoiner; + + +public final class WebSocketExtensionNegotiation { + + private final List extensions; + private final List responseData; + + WebSocketExtensionNegotiation( + final List extensions, + final List responseData) { + this.extensions = extensions != null ? extensions : Collections.emptyList(); + this.responseData = responseData != null ? responseData : Collections.emptyList(); + } + + public List getExtensions() { + return extensions; + } + + public List getResponseData() { + return responseData; + } + + public String formatResponseHeader() { + final StringJoiner joiner = new StringJoiner(", "); + for (final WebSocketExtensionData data : responseData) { + joiner.add(data.format()); + } + return joiner.length() > 0 ? joiner.toString() : null; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistry.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistry.java new file mode 100644 index 000000000..12fa259f3 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistry.java @@ -0,0 +1,74 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.util.Args; + +public final class WebSocketExtensionRegistry { + + private final Map factories; + + public WebSocketExtensionRegistry() { + this.factories = new LinkedHashMap<>(); + } + + public WebSocketExtensionRegistry register(final WebSocketExtensionFactory factory) { + Args.notNull(factory, "Extension factory"); + factories.put(factory.getName(), factory); + return this; + } + + public WebSocketExtensionNegotiation negotiate( + final List requested, + final boolean server) throws WebSocketException { + final List extensions = new ArrayList<>(); + final List responseData = new ArrayList<>(); + if (requested != null) { + for (final WebSocketExtensionData request : requested) { + final WebSocketExtensionFactory factory = factories.get(request.getName()); + if (factory != null) { + final WebSocketExtension extension = factory.create(request, server); + if (extension != null) { + extensions.add(extension); + responseData.add(extension.getResponseData()); + } + } + } + } + return new WebSocketExtensionNegotiation(extensions, responseData); + } + + public static WebSocketExtensionRegistry createDefault() { + return new WebSocketExtensionRegistry() + .register(new PerMessageDeflateExtensionFactory()); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensions.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensions.java new file mode 100644 index 000000000..6c853b50f --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensions.java @@ -0,0 +1,63 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HeaderElement; +import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.http.message.MessageSupport; +import org.apache.hc.core5.util.TextUtils; + +public final class WebSocketExtensions { + + private WebSocketExtensions() { + } + + public static List parse(final Header header) { + final List extensions = new ArrayList<>(); + if (header == null) { + return extensions; + } + for (final HeaderElement element : MessageSupport.parseElements(header)) { + final String name = element.getName(); + if (TextUtils.isBlank(name)) { + continue; + } + final Map params = new LinkedHashMap<>(); + for (final NameValuePair param : element.getParameters()) { + params.put(param.getName(), param.getValue()); + } + extensions.add(new WebSocketExtensionData(name, params)); + } + return extensions; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrame.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrame.java new file mode 100644 index 000000000..1460628f3 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrame.java @@ -0,0 +1,80 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.nio.ByteBuffer; + +import org.apache.hc.core5.util.Args; + +public final class WebSocketFrame { + + private final boolean fin; + private final boolean rsv1; + private final boolean rsv2; + private final boolean rsv3; + private final WebSocketFrameType type; + private final ByteBuffer payload; + + public WebSocketFrame( + final boolean fin, + final boolean rsv1, + final boolean rsv2, + final boolean rsv3, + final WebSocketFrameType type, + final ByteBuffer payload) { + this.fin = fin; + this.rsv1 = rsv1; + this.rsv2 = rsv2; + this.rsv3 = rsv3; + this.type = Args.notNull(type, "Frame type"); + this.payload = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0).asReadOnlyBuffer(); + } + + public boolean isFin() { + return fin; + } + + public boolean isRsv1() { + return rsv1; + } + + public boolean isRsv2() { + return rsv2; + } + + public boolean isRsv3() { + return rsv3; + } + + public WebSocketFrameType getType() { + return type; + } + + public ByteBuffer getPayload() { + return payload.asReadOnlyBuffer(); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameReader.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameReader.java new file mode 100644 index 000000000..f6049bc02 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameReader.java @@ -0,0 +1,165 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.core5.util.Args; + +class WebSocketFrameReader { + + private final WebSocketConfig config; + private final InputStream inputStream; + private final List extensions; + private final WebSocketExtension rsv1Extension; + private final WebSocketExtension rsv2Extension; + private final WebSocketExtension rsv3Extension; + private boolean continuationCompressed; + + WebSocketFrameReader(final WebSocketConfig config, final InputStream inputStream, final List extensions) { + this.config = Args.notNull(config, "WebSocket config"); + this.inputStream = Args.notNull(inputStream, "Input stream"); + this.extensions = extensions != null ? extensions : Collections.emptyList(); + this.rsv1Extension = selectExtension(WebSocketExtension::usesRsv1); + this.rsv2Extension = selectExtension(WebSocketExtension::usesRsv2); + this.rsv3Extension = selectExtension(WebSocketExtension::usesRsv3); + this.continuationCompressed = false; + } + + WebSocketFrame readFrame() throws IOException { + final int b1 = inputStream.read(); + if (b1 == -1) { + return null; + } + final int b2 = readByte(); + final boolean fin = (b1 & 0x80) != 0; + final boolean rsv1 = (b1 & 0x40) != 0; + final boolean rsv2 = (b1 & 0x20) != 0; + final boolean rsv3 = (b1 & 0x10) != 0; + final int opcode = b1 & 0x0F; + final WebSocketFrameType type = WebSocketFrameType.fromOpcode(opcode); + if (type == null) { + throw new WebSocketException("Unsupported opcode: " + opcode); + } + if (type.isControl() && (rsv1 || rsv2 || rsv3)) { + throw new WebSocketException("Invalid RSV bits for control frame"); + } + if (type == WebSocketFrameType.CONTINUATION && rsv1) { + throw new WebSocketException("RSV1 must be 0 on continuation frames"); + } + if (rsv1 && rsv1Extension == null) { + throw new WebSocketException("Unexpected RSV1 bit"); + } + if (rsv2 && rsv2Extension == null) { + throw new WebSocketException("Unexpected RSV2 bit"); + } + if (rsv3 && rsv3Extension == null) { + throw new WebSocketException("Unexpected RSV3 bit"); + } + final boolean masked = (b2 & 0x80) != 0; + if (!masked) { + throw new WebSocketException("Client frame is not masked"); + } + long len = b2 & 0x7F; + if (len == 126) { + len = ((readByte() & 0xFF) << 8) | (readByte() & 0xFF); + } else if (len == 127) { + len = 0; + for (int i = 0; i < 8; i++) { + len = (len << 8) | (readByte() & 0xFF); + } + } + if (len > Integer.MAX_VALUE) { + throw new WebSocketException("Frame payload too large: " + len); + } + if (len > config.getMaxFramePayloadSize()) { + throw new WebSocketException("Frame payload exceeds limit: " + len); + } + final byte[] maskKey = new byte[4]; + readFully(maskKey); + final byte[] payload = new byte[(int) len]; + readFully(payload); + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (payload[i] ^ maskKey[i % 4]); + } + ByteBuffer data = ByteBuffer.wrap(payload); + if (rsv1 && rsv1Extension != null) { + data = rsv1Extension.decode(type, fin, data); + continuationCompressed = !fin && (type == WebSocketFrameType.TEXT || type == WebSocketFrameType.BINARY); + } else if (type == WebSocketFrameType.CONTINUATION && continuationCompressed && rsv1Extension != null) { + data = rsv1Extension.decode(type, fin, data); + if (fin) { + continuationCompressed = false; + } + } else if (type == WebSocketFrameType.CONTINUATION && fin) { + continuationCompressed = false; + } + if (rsv2 && rsv2Extension != null) { + data = rsv2Extension.decode(type, fin, data); + } + if (rsv3 && rsv3Extension != null) { + data = rsv3Extension.decode(type, fin, data); + } + return new WebSocketFrame(fin, false, false, false, type, data); + } + + private WebSocketExtension selectExtension(final java.util.function.Predicate predicate) { + WebSocketExtension selected = null; + for (final WebSocketExtension extension : extensions) { + if (predicate.test(extension)) { + if (selected != null) { + throw new IllegalStateException("Multiple extensions use the same RSV bit"); + } + selected = extension; + } + } + return selected; + } + + private int readByte() throws IOException { + final int b = inputStream.read(); + if (b == -1) { + throw new IOException("Unexpected end of stream"); + } + return b; + } + + private void readFully(final byte[] buffer) throws IOException { + int off = 0; + while (off < buffer.length) { + final int read = inputStream.read(buffer, off, buffer.length - off); + if (read == -1) { + throw new IOException("Unexpected end of stream"); + } + off += read; + } + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameType.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameType.java new file mode 100644 index 000000000..e57dd8908 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameType.java @@ -0,0 +1,60 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +public enum WebSocketFrameType { + + CONTINUATION(0x0), + TEXT(0x1), + BINARY(0x2), + CLOSE(0x8), + PING(0x9), + PONG(0xA); + + private final int opcode; + + WebSocketFrameType(final int opcode) { + this.opcode = opcode; + } + + public int getOpcode() { + return opcode; + } + + public boolean isControl() { + return opcode >= 0x8; + } + + public static WebSocketFrameType fromOpcode(final int opcode) { + for (final WebSocketFrameType type : values()) { + if (type.opcode == opcode) { + return type; + } + } + return null; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameWriter.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameWriter.java new file mode 100644 index 000000000..69ef31e5b --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameWriter.java @@ -0,0 +1,135 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.core5.util.Args; + +class WebSocketFrameWriter { + + private final OutputStream outputStream; + private final List extensions; + + WebSocketFrameWriter(final OutputStream outputStream, final List extensions) { + this.outputStream = Args.notNull(outputStream, "Output stream"); + this.extensions = extensions != null ? extensions : Collections.emptyList(); + } + + void writeText(final String text) throws IOException, WebSocketException { + Args.notNull(text, "Text"); + writeDataFrame(WebSocketFrameType.TEXT, ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8))); + } + + void writeBinary(final ByteBuffer payload) throws IOException, WebSocketException { + writeDataFrame(WebSocketFrameType.BINARY, payload); + } + + void writePing(final ByteBuffer payload) throws IOException { + writeFrame(WebSocketFrameType.PING, payload, false, false, false); + } + + void writePong(final ByteBuffer payload) throws IOException { + writeFrame(WebSocketFrameType.PONG, payload, false, false, false); + } + + void writeClose(final int statusCode, final String reason) throws IOException { + final byte[] reasonBytes = reason != null ? reason.getBytes(StandardCharsets.UTF_8) : new byte[0]; + final int len = 2 + reasonBytes.length; + final ByteBuffer buffer = ByteBuffer.allocate(len); + buffer.put((byte) ((statusCode >> 8) & 0xFF)); + buffer.put((byte) (statusCode & 0xFF)); + buffer.put(reasonBytes); + buffer.flip(); + writeFrame(WebSocketFrameType.CLOSE, buffer, false, false, false); + } + + private void writeDataFrame(final WebSocketFrameType type, final ByteBuffer payload) throws IOException, WebSocketException { + ByteBuffer data = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0); + boolean rsv1 = false; + boolean rsv2 = false; + boolean rsv3 = false; + for (final WebSocketExtension extension : extensions) { + if (extension.usesRsv1()) { + rsv1 = true; + } + if (extension.usesRsv2()) { + rsv2 = true; + } + if (extension.usesRsv3()) { + rsv3 = true; + } + data = extension.encode(type, true, data); + } + writeFrame(type, data, rsv1, rsv2, rsv3); + } + + private void writeFrame( + final WebSocketFrameType type, + final ByteBuffer payload, + final boolean rsv1, + final boolean rsv2, + final boolean rsv3) throws IOException { + Args.notNull(type, "Frame type"); + final ByteBuffer buffer = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0); + final int payloadLen = buffer.remaining(); + int firstByte = 0x80 | (type.getOpcode() & 0x0F); + if (rsv1) { + firstByte |= 0x40; + } + if (rsv2) { + firstByte |= 0x20; + } + if (rsv3) { + firstByte |= 0x10; + } + outputStream.write(firstByte); + if (payloadLen <= 125) { + outputStream.write(payloadLen); + } else if (payloadLen <= 0xFFFF) { + outputStream.write(126); + outputStream.write((payloadLen >> 8) & 0xFF); + outputStream.write(payloadLen & 0xFF); + } else { + outputStream.write(127); + for (int i = 7; i >= 0; i--) { + outputStream.write((int) (((long) payloadLen >> (i * 8)) & 0xFF)); + } + } + if (payloadLen > 0) { + final byte[] data = new byte[payloadLen]; + buffer.get(data); + outputStream.write(data); + } + outputStream.flush(); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandler.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandler.java new file mode 100644 index 000000000..1383dbf4a --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandler.java @@ -0,0 +1,58 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.nio.ByteBuffer; +import java.util.List; + +public interface WebSocketHandler { + + default String selectSubprotocol(final List offered) { + return null; + } + + default void onOpen(final WebSocketSession session) { + } + + default void onText(final WebSocketSession session, final String text) throws WebSocketException { + } + + default void onBinary(final WebSocketSession session, final ByteBuffer data) throws WebSocketException { + } + + default void onPing(final WebSocketSession session, final ByteBuffer data) throws WebSocketException { + } + + default void onPong(final WebSocketSession session, final ByteBuffer data) throws WebSocketException { + } + + default void onClose(final WebSocketSession session, final int statusCode, final String reason) { + } + + default void onError(final WebSocketSession session, final Exception cause) { + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandshake.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandshake.java new file mode 100644 index 000000000..dbcc251fd --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandshake.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.core5.websocket; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.message.MessageSupport; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TextUtils; + +public final class WebSocketHandshake { + + private static final String SUPPORTED_VERSION = "13"; + + private WebSocketHandshake() { + } + + public static boolean isWebSocketUpgrade(final HttpRequest request) { + if (request == null || request.getMethod() == null) { + return false; + } + if (!Method.GET.isSame(request.getMethod())) { + return false; + } + if (!containsToken(request, HttpHeaders.CONNECTION, "Upgrade")) { + return false; + } + final Header upgradeHeader = request.getFirstHeader(HttpHeaders.UPGRADE); + if (upgradeHeader == null || !"websocket".equalsIgnoreCase(upgradeHeader.getValue())) { + return false; + } + final Header versionHeader = request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION); + if (versionHeader == null || !SUPPORTED_VERSION.equals(versionHeader.getValue())) { + return false; + } + final Header keyHeader = request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_KEY); + return keyHeader != null && !TextUtils.isBlank(keyHeader.getValue()); + } + + public static String createAcceptKey(final String key) throws WebSocketException { + try { + Args.notBlank(key, "WebSocket key"); + final MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + final String acceptSource = key.trim() + WebSocketConstants.WEBSOCKET_GUID; + final byte[] digest = sha1.digest(acceptSource.getBytes(StandardCharsets.ISO_8859_1)); + return java.util.Base64.getEncoder().encodeToString(digest); + } catch (final Exception ex) { + throw new WebSocketException("Unable to compute Sec-WebSocket-Accept", ex); + } + } + + public static List parseSubprotocols(final Header header) { + final List protocols = new ArrayList<>(); + if (header == null) { + return protocols; + } + for (final String token : MessageSupport.parseTokens(header)) { + if (!TextUtils.isBlank(token)) { + protocols.add(token); + } + } + return protocols; + } + + private static boolean containsToken(final HttpRequest request, final String headerName, final String token) { + for (final Header hdr : request.getHeaders(headerName)) { + for (final String value : MessageSupport.parseTokens(hdr)) { + if (token.equalsIgnoreCase(value)) { + return true; + } + } + } + return false; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketSession.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketSession.java new file mode 100644 index 000000000..4fe9e78f6 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketSession.java @@ -0,0 +1,162 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; + +public final class WebSocketSession { + + private final WebSocketConfig config; + private final InputStream inputStream; + private final OutputStream outputStream; + private final SocketAddress remoteAddress; + private final SocketAddress localAddress; + private final WebSocketFrameReader reader; + private final WebSocketFrameWriter writer; + private final ReentrantLock writeLock = new ReentrantLock(); + private volatile boolean closeSent; + + public WebSocketSession( + final WebSocketConfig config, + final InputStream inputStream, + final OutputStream outputStream, + final SocketAddress remoteAddress, + final SocketAddress localAddress, + final List extensions) { + this.config = config != null ? config : WebSocketConfig.DEFAULT; + this.inputStream = Args.notNull(inputStream, "Input stream"); + this.outputStream = Args.notNull(outputStream, "Output stream"); + this.remoteAddress = remoteAddress; + this.localAddress = localAddress; + final List negotiated = extensions != null ? extensions : Collections.emptyList(); + this.reader = new WebSocketFrameReader(this.config, this.inputStream, negotiated); + this.writer = new WebSocketFrameWriter(this.outputStream, negotiated); + this.closeSent = false; + } + + public SocketAddress getRemoteAddress() { + return remoteAddress; + } + + public SocketAddress getLocalAddress() { + return localAddress; + } + + public WebSocketFrame readFrame() throws IOException { + return reader.readFrame(); + } + + public void sendText(final String text) throws IOException, WebSocketException { + Args.notNull(text, "Text"); + writeLock.lock(); + try { + writer.writeText(text); + } finally { + writeLock.unlock(); + } + } + + public void sendBinary(final ByteBuffer data) throws IOException, WebSocketException { + Args.notNull(data, "Binary payload"); + writeLock.lock(); + try { + writer.writeBinary(data); + } finally { + writeLock.unlock(); + } + } + + public void sendPing(final ByteBuffer data) throws IOException { + writeLock.lock(); + try { + writer.writePing(data); + } finally { + writeLock.unlock(); + } + } + + public void sendPong(final ByteBuffer data) throws IOException { + writeLock.lock(); + try { + writer.writePong(data); + } finally { + writeLock.unlock(); + } + } + + public void close(final int statusCode, final String reason) throws IOException { + writeLock.lock(); + try { + if (!closeSent) { + writer.writeClose(statusCode, reason); + closeSent = true; + } + } finally { + writeLock.unlock(); + } + } + + public void close(final WebSocketCloseStatus status) throws IOException { + final int code = status != null ? status.getCode() : WebSocketCloseStatus.NORMAL.getCode(); + close(code, ""); + } + + public void closeQuietly() { + try { + close(WebSocketCloseStatus.NORMAL.getCode(), ""); + } catch (final IOException ignore) { + // ignore + } + } + + public static String decodeText(final ByteBuffer payload) throws WebSocketException { + try { + final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + final CharBuffer chars = decoder.decode(payload.asReadOnlyBuffer()); + return chars.toString(); + } catch (final CharacterCodingException ex) { + throw new WebSocketProtocolException(1007, "Invalid UTF-8 payload"); + } + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolException.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolException.java new file mode 100644 index 000000000..85d708d9d --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolException.java @@ -0,0 +1,41 @@ +/* + * ==================================================================== + * 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.core5.websocket.exceptions; + + +import org.apache.hc.core5.annotation.Internal; + +@Internal +public final class WebSocketProtocolException extends RuntimeException { + + public final int closeCode; + + public WebSocketProtocolException(final int closeCode, final String message) { + super(message); + this.closeCode = closeCode; + } +} \ No newline at end of file diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/package-info.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/package-info.java new file mode 100644 index 000000000..d68edcf81 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +/** + * WebSocket-specific exception types. + * + *

Primarily used to signal protocol-level errors with associated + * close codes.

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket.exceptions; diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/ExtensionChain.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/ExtensionChain.java new file mode 100644 index 000000000..03190a9eb --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/ExtensionChain.java @@ -0,0 +1,135 @@ +/* + * ==================================================================== + * 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.core5.websocket.extension; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Simple single-step chain; if multiple extensions are added they are applied in order. + * Only the FIRST extension can contribute the RSV bit (RSV1 in practice). + */ +@Internal +public final class ExtensionChain { + private final List exts = new ArrayList<>(); + + public void add(final WebSocketExtensionChain e) { + if (e != null) { + exts.add(e); + } + } + + public boolean isEmpty() { + return exts.isEmpty(); + } + + /** + * RSV bits used by the first extension in the chain (if any). + * Only the first extension may contribute RSV bits. + */ + public int rsvMask() { + if (exts.isEmpty()) { + return 0; + } + return exts.get(0).rsvMask(); + } + + /** + * App-thread encoder chain. + */ + public EncodeChain newEncodeChain() { + final List encs = new ArrayList<>(exts.size()); + for (final WebSocketExtensionChain e : exts) { + encs.add(e.newEncoder()); + } + return new EncodeChain(encs); + } + + /** + * I/O-thread decoder chain. + */ + public DecodeChain newDecodeChain() { + final List decs = new ArrayList<>(exts.size()); + for (final WebSocketExtensionChain e : exts) { + decs.add(e.newDecoder()); + } + return new DecodeChain(decs); + } + + // ---------------------- + + public static final class EncodeChain { + private final List encs; + + public EncodeChain(final List encs) { + this.encs = encs; + } + + /** + * Encode one fragment through the chain; note RSV flag for the first extension. + * Returns {@link WebSocketExtensionChain.Encoded}. + */ + public WebSocketExtensionChain.Encoded encode(final byte[] data, final boolean first, final boolean fin) { + if (encs.isEmpty()) { + return new WebSocketExtensionChain.Encoded(data, false); + } + byte[] out = data; + boolean setRsv1 = false; + boolean firstExt = true; + for (final WebSocketExtensionChain.Encoder e : encs) { + final WebSocketExtensionChain.Encoded res = e.encode(out, first, fin); + out = res.payload; + if (first && firstExt && res.setRsvOnFirst) { + setRsv1 = true; + } + firstExt = false; + } + return new WebSocketExtensionChain.Encoded(out, setRsv1); + } + } + + public static final class DecodeChain { + private final List decs; + + public DecodeChain(final List decs) { + this.decs = decs; + } + + /** + * Decode a full message (reverse order if stacking). + */ + public byte[] decode(final byte[] data) throws Exception { + byte[] out = data; + for (int i = decs.size() - 1; i >= 0; i--) { + out = decs.get(i).decode(out); + } + return out; + } + } +} \ No newline at end of file diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/PerMessageDeflate.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/PerMessageDeflate.java new file mode 100644 index 000000000..a8ee07743 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/PerMessageDeflate.java @@ -0,0 +1,195 @@ +/* + * ==================================================================== + * 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.core5.websocket.extension; + +import java.io.ByteArrayOutputStream; +import java.util.zip.Deflater; +import java.util.zip.Inflater; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.websocket.frame.FrameHeaderBits; + +/** + * permessage-deflate (RFC 7692). + * + *

Window bit parameters are negotiated during the handshake: + * {@code client_max_window_bits} limits the client's compression window (client->server), + * while {@code server_max_window_bits} limits the server's compression window (server->client). + * The decoder can accept any server window size (8..15). The encoder currently requires + * {@code client_max_window_bits} to be 15, due to JDK Deflater limitations.

+ */ +@Internal +public final class PerMessageDeflate implements WebSocketExtensionChain { + private static final byte[] TAIL = new byte[]{0x00, 0x00, (byte) 0xFF, (byte) 0xFF}; + + private final boolean enabled; + private final boolean serverNoContextTakeover; + private final boolean clientNoContextTakeover; + private final Integer clientMaxWindowBits; // negotiated or null + private final Integer serverMaxWindowBits; // negotiated or null + + public PerMessageDeflate(final boolean enabled, + final boolean serverNoContextTakeover, + final boolean clientNoContextTakeover, + final Integer clientMaxWindowBits, + final Integer serverMaxWindowBits) { + this.enabled = enabled; + this.serverNoContextTakeover = serverNoContextTakeover; + this.clientNoContextTakeover = clientNoContextTakeover; + this.clientMaxWindowBits = clientMaxWindowBits; + this.serverMaxWindowBits = serverMaxWindowBits; + } + + @Override + public int rsvMask() { + return FrameHeaderBits.RSV1; + } + + @Override + public Encoder newEncoder() { + if (!enabled) { + return (data, first, fin) -> new Encoded(data, false); + } + return new Encoder() { + private final Deflater def = new Deflater(Deflater.DEFAULT_COMPRESSION, true); // raw DEFLATE + + @Override + public Encoded encode(final byte[] data, final boolean first, final boolean fin) { + final byte[] out = first && fin + ? compressMessage(data) + : compressFragment(data, fin); + // RSV1 on first compressed data frame only + return new Encoded(out, first); + } + + private byte[] compressMessage(final byte[] data) { + return doDeflate(data, true, true, clientNoContextTakeover); + } + + private byte[] compressFragment(final byte[] data, final boolean fin) { + return doDeflate(data, fin, true, fin && clientNoContextTakeover); + } + + private byte[] doDeflate(final byte[] data, + final boolean fin, + final boolean stripTail, + final boolean maybeReset) { + if (data == null || data.length == 0) { + if (fin && maybeReset) { + def.reset(); + } + return new byte[0]; + } + def.setInput(data); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, data.length / 2)); + final byte[] buf = new byte[8192]; + while (!def.needsInput()) { + final int n = def.deflate(buf, 0, buf.length, Deflater.SYNC_FLUSH); + if (n > 0) { + out.write(buf, 0, n); + } else { + break; + } + } + byte[] all = out.toByteArray(); + if (stripTail && all.length >= 4) { + final int newLen = all.length - 4; // strip 00 00 FF FF + if (newLen <= 0) { + all = new byte[0]; + } else { + final byte[] trimmed = new byte[newLen]; + System.arraycopy(all, 0, trimmed, 0, newLen); + all = trimmed; + } + } + if (fin && maybeReset) { + def.reset(); + } + return all; + } + }; + } + + @Override + public Decoder newDecoder() { + if (!enabled) { + return payload -> payload; + } + return new Decoder() { + private final Inflater inf = new Inflater(true); + + @Override + public byte[] decode(final byte[] compressedMessage) throws Exception { + final byte[] withTail; + if (compressedMessage == null || compressedMessage.length == 0) { + withTail = TAIL.clone(); + } else { + withTail = new byte[compressedMessage.length + 4]; + System.arraycopy(compressedMessage, 0, withTail, 0, compressedMessage.length); + System.arraycopy(TAIL, 0, withTail, compressedMessage.length, 4); + } + + inf.setInput(withTail); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, withTail.length * 2)); + final byte[] buf = new byte[8192]; + while (!inf.needsInput()) { + final int n = inf.inflate(buf); + if (n > 0) { + out.write(buf, 0, n); + } else { + break; + } + } + if (serverNoContextTakeover) { + inf.reset(); + } + return out.toByteArray(); + } + }; + } + + // optional getters for logging/tests + public boolean isEnabled() { + return enabled; + } + + public boolean isServerNoContextTakeover() { + return serverNoContextTakeover; + } + + public boolean isClientNoContextTakeover() { + return clientNoContextTakeover; + } + + public Integer getClientMaxWindowBits() { + return clientMaxWindowBits; + } + + public Integer getServerMaxWindowBits() { + return serverMaxWindowBits; + } +} \ No newline at end of file diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/WebSocketExtensionChain.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/WebSocketExtensionChain.java new file mode 100644 index 000000000..006ee8eea --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/WebSocketExtensionChain.java @@ -0,0 +1,80 @@ +/* + * ==================================================================== + * 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.core5.websocket.extension; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Generic extension hook for payload transform (e.g., permessage-deflate). + * Implementations may return RSV mask (usually RSV1) and indicate whether + * the first frame of a message should set RSV. + */ +@Internal +public interface WebSocketExtensionChain { + + /** + * RSV bits this extension uses on the first data frame (e.g. 0x40 for RSV1). + */ + int rsvMask(); + + /** + * Create a thread-confined encoder instance (app thread). + */ + Encoder newEncoder(); + + /** + * Create a thread-confined decoder instance (I/O thread). + */ + Decoder newDecoder(); + + /** + * Encoded fragment result. + */ + final class Encoded { + public final byte[] payload; + public final boolean setRsvOnFirst; + + public Encoded(final byte[] payload, final boolean setRsvOnFirst) { + this.payload = payload; + this.setRsvOnFirst = setRsvOnFirst; + } + } + + interface Encoder { + /** + * Encode one fragment; return transformed payload and whether to set RSV on FIRST frame. + */ + Encoded encode(byte[] data, boolean first, boolean fin); + } + + interface Decoder { + /** + * Decode a full message produced with this extension. + */ + byte[] decode(byte[] payload) throws Exception; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/package-info.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/package-info.java new file mode 100644 index 000000000..ab6f4cae5 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +/** + * WebSocket extension SPI and implementations. + * + *

Includes the generic {@code Extension} SPI, chaining support, and a + * permessage-deflate (RFC 7692) implementation.

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket.extension; diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameHeaderBits.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameHeaderBits.java new file mode 100644 index 000000000..7b72942d4 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameHeaderBits.java @@ -0,0 +1,49 @@ +/* + * ==================================================================== + * 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.core5.websocket.frame; + +import org.apache.hc.core5.annotation.Internal; + +/** + * WebSocket frame header bit masks (RFC 6455 §5.2). + */ +@Internal +public final class FrameHeaderBits { + private FrameHeaderBits() { + } + + // First header byte + public static final int FIN = 0x80; + public static final int RSV1 = 0x40; + public static final int RSV2 = 0x20; + public static final int RSV3 = 0x10; + // low 4 bits (0x0F) are opcode + + // Second header byte + public static final int MASK_BIT = 0x80; // client->server payload mask bit + // low 7 bits (0x7F) are payload len indicator +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameOpcode.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameOpcode.java new file mode 100644 index 000000000..8bcdb9aaa --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameOpcode.java @@ -0,0 +1,88 @@ +/* + * ==================================================================== + * 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.core5.websocket.frame; + +import org.apache.hc.core5.annotation.Internal; + +/** + * RFC 6455 opcode constants + helpers. + */ +@Internal +public final class FrameOpcode { + public static final int CONT = 0x0; + public static final int TEXT = 0x1; + public static final int BINARY = 0x2; + public static final int CLOSE = 0x8; + public static final int PING = 0x9; + public static final int PONG = 0xA; + + private FrameOpcode() { + } + + /** + * Control frames have the high bit set in the low nibble (0x8–0xF). + */ + public static boolean isControl(final int opcode) { + return (opcode & 0x08) != 0; + } + + /** + * Data opcodes (not continuation). + */ + public static boolean isData(final int opcode) { + return opcode == TEXT || opcode == BINARY; + } + + /** + * Continuation opcode. + */ + public static boolean isContinuation(final int opcode) { + return opcode == CONT; + } + + /** + * Optional: human-readable name for debugging. + */ + public static String name(final int opcode) { + switch (opcode) { + case CONT: + return "CONT"; + case TEXT: + return "TEXT"; + case BINARY: + return "BINARY"; + case CLOSE: + return "CLOSE"; + case PING: + return "PING"; + case PONG: + return "PONG"; + default: + return "0x" + Integer.toHexString(opcode); + } + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/WebSocketFrameWriter.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/WebSocketFrameWriter.java new file mode 100644 index 000000000..a279217b1 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/WebSocketFrameWriter.java @@ -0,0 +1,189 @@ +/* + * ==================================================================== + * 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.core5.websocket.frame; + +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.FIN; +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.MASK_BIT; +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV1; +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV2; +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV3; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ThreadLocalRandom; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.websocket.message.CloseCodec; + +/** + * RFC 6455 frame writer with helpers to build into an existing target buffer. + * + * @since 5.7 + */ +@Internal +public final class WebSocketFrameWriter { + + // -- Text/Binary ----------------------------------------------------------- + + public ByteBuffer text(final CharSequence data, final boolean fin) { + final ByteBuffer payload = data == null ? ByteBuffer.allocate(0) + : StandardCharsets.UTF_8.encode(data.toString()); + // Client → server MUST be masked + return frame(FrameOpcode.TEXT, payload, fin, true); + } + + public ByteBuffer binary(final ByteBuffer data, final boolean fin) { + final ByteBuffer payload = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer(); + return frame(FrameOpcode.BINARY, payload, fin, true); + } + + // -- Control frames (FIN=true, payload ≤ 125, never compressed) ----------- + + public ByteBuffer ping(final ByteBuffer payloadOrNull) { + final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer(); + if (p.remaining() > 125) { + throw new IllegalArgumentException("PING payload > 125 bytes"); + } + return frame(FrameOpcode.PING, p, true, true); + } + + public ByteBuffer pong(final ByteBuffer payloadOrNull) { + final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer(); + if (p.remaining() > 125) { + throw new IllegalArgumentException("PONG payload > 125 bytes"); + } + return frame(FrameOpcode.PONG, p, true, true); + } + + public ByteBuffer close(final int code, final String reason) { + if (!CloseCodec.isValidToSend(code)) { + throw new IllegalArgumentException("Invalid close code to send: " + code); + } + final String safeReason = CloseCodec.truncateReasonUtf8(reason); + final ByteBuffer reasonBuf = safeReason.isEmpty() + ? ByteBuffer.allocate(0) + : StandardCharsets.UTF_8.encode(safeReason); + + if (reasonBuf.remaining() > 123) { + throw new IllegalArgumentException("Close reason too long (UTF-8 bytes > 123)"); + } + + final ByteBuffer p = ByteBuffer.allocate(2 + reasonBuf.remaining()); + p.put((byte) (code >> 8 & 0xFF)); + p.put((byte) (code & 0xFF)); + if (reasonBuf.hasRemaining()) { + p.put(reasonBuf); + } + p.flip(); + return frame(FrameOpcode.CLOSE, p, true, true); + } + + public ByteBuffer closeEcho(final ByteBuffer payload) { + final ByteBuffer p = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer(); + if (p.remaining() > 125) { + throw new IllegalArgumentException("Close payload > 125 bytes"); + } + return frame(FrameOpcode.CLOSE, p, true, true); + } + + // -- Core framing ---------------------------------------------------------- + + public ByteBuffer frame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean mask) { + return frameWithRSV(opcode, payload, fin, mask, 0); + } + + public ByteBuffer frameWithRSV(final int opcode, final ByteBuffer payload, final boolean fin, + final boolean mask, final int rsvBits) { + final int len = payload == null ? 0 : payload.remaining(); + final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8; + final int maskLen = mask ? 4 : 0; + final ByteBuffer out = ByteBuffer.allocate(2 + hdrExtra + maskLen + len).order(ByteOrder.BIG_ENDIAN); + frameIntoWithRSV(opcode, payload, fin, mask, rsvBits, out); + out.flip(); + return out; + } + + public ByteBuffer frameInto(final int opcode, final ByteBuffer payload, final boolean fin, + final boolean mask, final ByteBuffer out) { + return frameIntoWithRSV(opcode, payload, fin, mask, 0, out); + } + + public ByteBuffer frameIntoWithRSV(final int opcode, final ByteBuffer payload, final boolean fin, + final boolean mask, final int rsvBits, final ByteBuffer out) { + final int len = payload == null ? 0 : payload.remaining(); + + if (FrameOpcode.isControl(opcode)) { + if (!fin) { + throw new IllegalArgumentException("Control frames must not be fragmented (FIN=false)"); + } + if (len > 125) { + throw new IllegalArgumentException("Control frame payload > 125 bytes"); + } + if ((rsvBits & (RSV1 | RSV2 | RSV3)) != 0) { + throw new IllegalArgumentException("RSV bits must be 0 on control frames"); + } + } + + final int finBit = fin ? FIN : 0; + out.put((byte) (finBit | rsvBits & (RSV1 | RSV2 | RSV3) | opcode & 0x0F)); + + if (len <= 125) { + out.put((byte) ((mask ? MASK_BIT : 0) | len)); + } else if (len <= 0xFFFF) { + out.put((byte) ((mask ? MASK_BIT : 0) | 126)); + out.putShort((short) len); + } else { + out.put((byte) ((mask ? MASK_BIT : 0) | 127)); + out.putLong(len & 0x7FFF_FFFF_FFFF_FFFFL); + } + + int[] mkey = null; + if (mask) { + mkey = new int[]{rnd(), rnd(), rnd(), rnd()}; + out.put((byte) mkey[0]).put((byte) mkey[1]).put((byte) mkey[2]).put((byte) mkey[3]); + } + + if (len > 0) { + final ByteBuffer src = payload.asReadOnlyBuffer(); + int i = 0; // simpler, safer mask index + while (src.hasRemaining()) { + int b = src.get() & 0xFF; + if (mask) { + b ^= mkey[i & 3]; + i++; + } + out.put((byte) b); + } + } + return out; + } + + private static int rnd() { + return ThreadLocalRandom.current().nextInt(256); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/package-info.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/package-info.java new file mode 100644 index 000000000..d50e6211e --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +/** + * Low-level WebSocket frame helpers. + * + *

Opcode constants, header bit masks, and frame writer utilities aligned + * with RFC 6455.

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket.frame; \ No newline at end of file diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/message/CloseCodec.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/message/CloseCodec.java new file mode 100644 index 000000000..b7deeaea0 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/message/CloseCodec.java @@ -0,0 +1,190 @@ +/* + * ==================================================================== + * 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.core5.websocket.message; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Helpers for RFC6455 CLOSE parsing & validation. + */ +@Internal +public final class CloseCodec { + + private CloseCodec() { + } + + + /** + * Reads the close status code from the payload buffer, if present. + * Returns {@code 1005} (“no status code present”) when the payload + * does not contain at least two bytes. + */ + public static int readCloseCode(final ByteBuffer payloadRO) { + if (payloadRO == null || payloadRO.remaining() < 2) { + return 1005; // “no status code present” + } + final int b1 = payloadRO.get() & 0xFF; + final int b2 = payloadRO.get() & 0xFF; + return (b1 << 8) | b2; + } + + /** + * Reads the close reason from the remaining bytes of the payload + * as UTF-8. Returns an empty string if there is no payload left. + */ + public static String readCloseReason(final ByteBuffer payloadRO) { + if (payloadRO == null || !payloadRO.hasRemaining()) { + return ""; + } + final ByteBuffer dup = payloadRO.slice(); + return StandardCharsets.UTF_8.decode(dup).toString(); + } + + // ---- RFC validation (sender & receiver) --------------------------------- + + /** + * RFC 6455 §7.4.2: MUST NOT appear on the wire. + */ + private static boolean isForbiddenOnWire(final int code) { + return code == 1005 || code == 1006 || code == 1015; + } + + /** + * Codes defined by RFC 6455 to send (and likewise valid to receive). + */ + private static boolean isRfcDefined(final int code) { + switch (code) { + case 1000: // normal + case 1001: // going away + case 1002: // protocol error + case 1003: // unsupported data + case 1007: // invalid payload data + case 1008: // policy violation + case 1009: // message too big + case 1010: // mandatory extension + case 1011: // internal error + return true; + default: + return false; + } + } + + /** + * Application/reserved range that may be sent by endpoints. + */ + private static boolean isAppRange(final int code) { + return code >= 3000 && code <= 4999; + } + + /** + * Validate a code we intend to PUT ON THE WIRE (sender-side). + */ + public static boolean isValidToSend(final int code) { + if (code < 0) { + return false; + } + if (isForbiddenOnWire(code)) { + return false; + } + return isRfcDefined(code) || isAppRange(code); + } + + /** + * Validate a code we PARSED FROM THE WIRE (receiver-side). + */ + public static boolean isValidToReceive(final int code) { + // 1005, 1006, 1015 must not appear on the wire + if (isForbiddenOnWire(code)) { + return false; + } + // Same allowed sets otherwise + return isRfcDefined(code) || isAppRange(code); + } + + // ---- Reason handling: max 123 bytes (2 bytes used by code) -------------- + + /** + * Returns a UTF-8 string truncated to ≤ 123 bytes, preserving code-points. + * This ensures that a CLOSE frame payload (2-byte status code + reason) + * never exceeds the 125-byte control frame limit. + */ + public static String truncateReasonUtf8(final String reason) { + if (reason == null || reason.isEmpty()) { + return ""; + } + final byte[] bytes = reason.getBytes(StandardCharsets.UTF_8); + if (bytes.length <= 123) { + return reason; + } + int i = 0; + int byteCount = 0; + while (i < reason.length()) { + final int cp = reason.codePointAt(i); + final int charCount = Character.charCount(cp); + final int extra = new String(Character.toChars(cp)) + .getBytes(StandardCharsets.UTF_8).length; + if (byteCount + extra > 123) { + break; + } + byteCount += extra; + i += charCount; + } + return reason.substring(0, i); + } + + // ---- Encoding ----------------------------------------------------------- + + /** + * Encodes a close status code and reason into a payload suitable for a + * CLOSE control frame: + * + *
+     *   payload[0] = high-byte of status code
+     *   payload[1] = low-byte of status code
+     *   payload[2..] = UTF-8 bytes of the (possibly truncated) reason
+     * 
+ *

+ * The reason is internally truncated to ≤ 123 UTF-8 bytes to ensure the + * resulting payload never exceeds the 125-byte control frame limit. + *

+ * The caller is expected to have already validated the status code with + * {@link #isValidToSend(int)}. + */ + public static byte[] encode(final int statusCode, final String reason) { + final String truncated = truncateReasonUtf8(reason); + final byte[] reasonBytes = truncated.getBytes(StandardCharsets.UTF_8); + // 2 bytes for the status code + final byte[] payload = new byte[2 + reasonBytes.length]; + payload[0] = (byte) ((statusCode >>> 8) & 0xFF); + payload[1] = (byte) (statusCode & 0xFF); + System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length); + return payload; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/message/package-info.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/message/package-info.java new file mode 100644 index 000000000..916931686 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/message/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +/** + * Message-level helpers and codecs. + * + *

Utilities for parsing and validating message semantics (e.g., CLOSE + * status code and reason handling).

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket.message; diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/package-info.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/package-info.java new file mode 100644 index 000000000..3394c70dd --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/package-info.java @@ -0,0 +1,37 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +/** + * WebSocket protocol support and helpers. + * + *

Subpackages such as {@code frame}, {@code extension}, {@code message}, + * {@code util}, and {@code exceptions} are internal implementation details + * and may change without notice.

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket; diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketContextKeys.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketContextKeys.java new file mode 100644 index 000000000..e006c9196 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketContextKeys.java @@ -0,0 +1,35 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +public final class WebSocketContextKeys { + + public static final String CONNECTION = "httpcore.websocket.connection"; + + private WebSocketContextKeys() { + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2Server.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2Server.java new file mode 100644 index 000000000..09f30b050 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2Server.java @@ -0,0 +1,99 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.ExecutionException; + +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer; +import org.apache.hc.core5.reactor.ListenerEndpoint; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TimeValue; + +public final class WebSocketH2Server { + + private final HttpAsyncServer server; + private final InetAddress localAddress; + private final int port; + private final URIScheme scheme; + private volatile ListenerEndpoint endpoint; + + WebSocketH2Server(final HttpAsyncServer server, final InetAddress localAddress, final int port, final URIScheme scheme) { + this.server = Args.notNull(server, "server"); + this.localAddress = localAddress; + this.port = port; + this.scheme = scheme != null ? scheme : URIScheme.HTTP; + } + + public void start() throws IOException { + server.start(); + try { + final InetSocketAddress address = localAddress != null + ? new InetSocketAddress(localAddress, Math.max(port, 0)) + : new InetSocketAddress(Math.max(port, 0)); + this.endpoint = server.listen(address, scheme).get(); + } catch (final ExecutionException ex) { + final Throwable cause = ex.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException(ex.getMessage(), ex); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException(ex.getMessage(), ex); + } + } + + public void stop() { + server.close(org.apache.hc.core5.io.CloseMode.GRACEFUL); + } + + public void initiateShutdown() { + server.initiateShutdown(); + } + + public InetAddress getInetAddress() { + if (endpoint != null && endpoint.getAddress() instanceof InetSocketAddress) { + return ((InetSocketAddress) endpoint.getAddress()).getAddress(); + } + return localAddress; + } + + public int getLocalPort() { + if (endpoint != null && endpoint.getAddress() instanceof InetSocketAddress) { + return ((InetSocketAddress) endpoint.getAddress()).getPort(); + } + return port; + } + + public void awaitShutdown(final TimeValue waitTime) throws InterruptedException { + server.awaitShutdown(waitTime); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrap.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrap.java new file mode 100644 index 000000000..e3be1339f --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrap.java @@ -0,0 +1,229 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; + +import org.apache.hc.core5.function.Supplier; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.Http1StreamListener; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer; +import org.apache.hc.core5.http.impl.routing.RequestRouter; +import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.impl.nio.H2StreamListener; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2ServerBootstrap; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketHandler; + +/** + * Bootstrap for HTTP/2 WebSocket servers using RFC 8441 (Extended CONNECT). + * + * @since 5.7 + */ +public final class WebSocketH2ServerBootstrap { + + private final List>> routeEntries; + private String canonicalHostName; + private int listenerPort; + private InetAddress localAddress; + private IOReactorConfig ioReactorConfig; + private HttpProcessor httpProcessor; + private HttpVersionPolicy versionPolicy; + private H2Config h2Config = H2Config.custom().setPushEnabled(false).build(); + private Http1Config http1Config = Http1Config.DEFAULT; + private CharCodingConfig charCodingConfig; + private TlsStrategy tlsStrategy; + private org.apache.hc.core5.util.Timeout handshakeTimeout; + private H2StreamListener h2StreamListener; + private Http1StreamListener http1StreamListener; + private WebSocketConfig webSocketConfig; + private WebSocketExtensionRegistry extensionRegistry; + private Executor executor; + + private WebSocketH2ServerBootstrap() { + this.routeEntries = new ArrayList<>(); + } + + public static WebSocketH2ServerBootstrap bootstrap() { + return new WebSocketH2ServerBootstrap(); + } + + public WebSocketH2ServerBootstrap setCanonicalHostName(final String canonicalHostName) { + this.canonicalHostName = canonicalHostName; + return this; + } + + public WebSocketH2ServerBootstrap setListenerPort(final int listenerPort) { + this.listenerPort = listenerPort; + return this; + } + + public WebSocketH2ServerBootstrap setLocalAddress(final InetAddress localAddress) { + this.localAddress = localAddress; + return this; + } + + public WebSocketH2ServerBootstrap setIOReactorConfig(final IOReactorConfig ioReactorConfig) { + this.ioReactorConfig = ioReactorConfig; + return this; + } + + public WebSocketH2ServerBootstrap setHttpProcessor(final HttpProcessor httpProcessor) { + this.httpProcessor = httpProcessor; + return this; + } + + public WebSocketH2ServerBootstrap setVersionPolicy(final HttpVersionPolicy versionPolicy) { + this.versionPolicy = versionPolicy; + return this; + } + + public WebSocketH2ServerBootstrap setH2Config(final H2Config h2Config) { + if (h2Config == null) { + this.h2Config = H2Config.custom().setPushEnabled(false).build(); + } else if (h2Config.isPushEnabled()) { + this.h2Config = H2Config.copy(h2Config).setPushEnabled(false).build(); + } else { + this.h2Config = h2Config; + } + return this; + } + + public WebSocketH2ServerBootstrap setHttp1Config(final Http1Config http1Config) { + this.http1Config = http1Config != null ? http1Config : Http1Config.DEFAULT; + return this; + } + + public WebSocketH2ServerBootstrap setCharCodingConfig(final CharCodingConfig charCodingConfig) { + this.charCodingConfig = charCodingConfig; + return this; + } + + public WebSocketH2ServerBootstrap setTlsStrategy(final TlsStrategy tlsStrategy) { + this.tlsStrategy = tlsStrategy; + return this; + } + + public WebSocketH2ServerBootstrap setHandshakeTimeout(final org.apache.hc.core5.util.Timeout handshakeTimeout) { + this.handshakeTimeout = handshakeTimeout; + return this; + } + + public WebSocketH2ServerBootstrap setH2StreamListener(final H2StreamListener h2StreamListener) { + this.h2StreamListener = h2StreamListener; + return this; + } + + public WebSocketH2ServerBootstrap setHttp1StreamListener(final Http1StreamListener http1StreamListener) { + this.http1StreamListener = http1StreamListener; + return this; + } + + public WebSocketH2ServerBootstrap setWebSocketConfig(final WebSocketConfig webSocketConfig) { + this.webSocketConfig = webSocketConfig; + return this; + } + + public WebSocketH2ServerBootstrap setExtensionRegistry(final WebSocketExtensionRegistry extensionRegistry) { + this.extensionRegistry = extensionRegistry; + return this; + } + + public WebSocketH2ServerBootstrap setExecutor(final Executor executor) { + this.executor = executor; + return this; + } + + public WebSocketH2ServerBootstrap register(final String uriPattern, final Supplier supplier) { + Args.notNull(uriPattern, "URI pattern"); + Args.notNull(supplier, "WebSocket handler supplier"); + this.routeEntries.add(new RequestRouter.Entry<>(uriPattern, supplier)); + return this; + } + + public WebSocketH2ServerBootstrap register(final String hostname, final String uriPattern, final Supplier supplier) { + Args.notNull(hostname, "Hostname"); + Args.notNull(uriPattern, "URI pattern"); + Args.notNull(supplier, "WebSocket handler supplier"); + this.routeEntries.add(new RequestRouter.Entry<>(hostname, uriPattern, supplier)); + return this; + } + + public WebSocketH2Server create() { + final WebSocketConfig cfg = webSocketConfig != null ? webSocketConfig : WebSocketConfig.DEFAULT; + final WebSocketExtensionRegistry ext = extensionRegistry != null + ? extensionRegistry + : WebSocketExtensionRegistry.createDefault(); + + final H2ServerBootstrap bootstrap = H2ServerBootstrap.bootstrap() + .setCanonicalHostName(canonicalHostName) + .setIOReactorConfig(ioReactorConfig) + .setHttpProcessor(httpProcessor) + .setVersionPolicy(versionPolicy != null ? versionPolicy : HttpVersionPolicy.FORCE_HTTP_2) + .setTlsStrategy(tlsStrategy) + .setHandshakeTimeout(handshakeTimeout) + .setStreamListener(h2StreamListener) + .setStreamListener(http1StreamListener); + + if (h2Config != null) { + bootstrap.setH2Config(h2Config); + } + if (http1Config != null) { + bootstrap.setHttp1Config(http1Config); + } + + if (charCodingConfig != null) { + bootstrap.setCharset(charCodingConfig); + } + + for (final RequestRouter.Entry> entry : routeEntries) { + final Supplier exchangeSupplier = () -> + new WebSocketH2ServerExchangeHandler(entry.route.handler.get(), cfg, ext, executor); + if (entry.uriAuthority != null) { + bootstrap.register(entry.uriAuthority.getHostName(), entry.route.pattern, exchangeSupplier); + } else { + bootstrap.register(entry.route.pattern, exchangeSupplier); + } + } + + final HttpAsyncServer server = bootstrap.create(); + final URIScheme scheme = tlsStrategy != null ? URIScheme.HTTPS : URIScheme.HTTP; + return new WebSocketH2Server(server, localAddress, listenerPort, scheme); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandler.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandler.java new file mode 100644 index 000000000..92e34d763 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandler.java @@ -0,0 +1,379 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.hc.core5.concurrent.DefaultThreadFactory; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.impl.BasicEntityDetails; +import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.DataStreamChannel; +import org.apache.hc.core5.http.nio.ResponseChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http2.H2PseudoRequestHeaders; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.WebSocketCloseStatus; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketConstants; +import org.apache.hc.core5.websocket.WebSocketExtensionNegotiation; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketExtensions; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketHandshake; +import org.apache.hc.core5.websocket.WebSocketSession; + +final class WebSocketH2ServerExchangeHandler implements AsyncServerExchangeHandler { + + private static final byte[] END_INBOUND = new byte[0]; + private static final ByteBuffer END_OUTBOUND = ByteBuffer.allocate(0); + + /** + * Default execution strategy (no explicit thread creation in the handler). + * Note: tasks are typically long-lived (one per WS session). The bootstrap should ideally inject an executor. + */ + private static final Executor DEFAULT_EXECUTOR = + Executors.newCachedThreadPool(new DefaultThreadFactory("ws-h2-server", true)); + + private final WebSocketHandler handler; + private final WebSocketConfig config; + private final WebSocketExtensionRegistry extensionRegistry; + private final Executor executor; + + private final BlockingQueue inbound = new LinkedBlockingQueue<>(); + private final BlockingQueue outbound = new LinkedBlockingQueue<>(); + + private final ReentrantLock outLock = new ReentrantLock(); + private ByteBuffer currentOutbound; + + private volatile boolean responseSent; + private volatile boolean outboundEnd; + private volatile boolean shutdown; + private volatile DataStreamChannel dataChannel; + + WebSocketH2ServerExchangeHandler( + final WebSocketHandler handler, + final WebSocketConfig config, + final WebSocketExtensionRegistry extensionRegistry) { + this(handler, config, extensionRegistry, null); + } + + WebSocketH2ServerExchangeHandler( + final WebSocketHandler handler, + final WebSocketConfig config, + final WebSocketExtensionRegistry extensionRegistry, + final Executor executor) { + this.handler = Args.notNull(handler, "WebSocket handler"); + this.config = config != null ? config : WebSocketConfig.DEFAULT; + this.extensionRegistry = extensionRegistry != null ? extensionRegistry : WebSocketExtensionRegistry.createDefault(); + this.executor = executor != null ? executor : DEFAULT_EXECUTOR; + this.responseSent = false; + this.outboundEnd = false; + this.shutdown = false; + } + + @Override + public void handleRequest( + final HttpRequest request, + final EntityDetails entityDetails, + final ResponseChannel responseChannel, + final HttpContext context) throws HttpException, IOException { + + if (!Method.CONNECT.isSame(request.getMethod())) { + responseChannel.sendResponse(new BasicHttpResponse(HttpStatus.SC_BAD_REQUEST), null, context); + return; + } + + final String protocol = request.getFirstHeader(H2PseudoRequestHeaders.PROTOCOL) != null + ? request.getFirstHeader(H2PseudoRequestHeaders.PROTOCOL).getValue() + : null; + if (!"websocket".equalsIgnoreCase(protocol)) { + responseChannel.sendResponse(new BasicHttpResponse(HttpStatus.SC_BAD_REQUEST), null, context); + return; + } + + final WebSocketExtensionNegotiation negotiation = extensionRegistry.negotiate( + WebSocketExtensions.parse(request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER)), + true); + + final BasicHttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK); + final String extensionsHeader = negotiation.formatResponseHeader(); + if (extensionsHeader != null) { + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER, extensionsHeader); + } + + final List offeredProtocols = WebSocketHandshake.parseSubprotocols( + request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER)); + final String protocolResponse = handler.selectSubprotocol(offeredProtocols); + if (protocolResponse != null) { + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER, protocolResponse); + } + + responseChannel.sendResponse(response, new BasicEntityDetails(-1, null), context); + responseSent = true; + + final InputStream inputStream = new QueueInputStream(inbound); + final OutputStream outputStream = new QueueOutputStream(outbound); + final WebSocketSession session = new WebSocketSession( + config, inputStream, outputStream, null, null, negotiation.getExtensions()); + + executor.execute(() -> { + try { + handler.onOpen(session); + new WebSocketServerProcessor(session, handler, config.getMaxMessageSize()).process(); + } catch (final Exception ex) { + handler.onError(session, ex); + try { + session.close(WebSocketCloseStatus.INTERNAL_ERROR.getCode(), "WebSocket error"); + } catch (final IOException ignore) { + // ignore + } + } finally { + shutdown = true; + outbound.offer(END_OUTBOUND); + inbound.offer(END_INBOUND); + + final DataStreamChannel channel = dataChannel; + if (channel != null) { + channel.requestOutput(); + } + } + }); + } + + @Override + public void updateCapacity(final CapacityChannel capacityChannel) throws IOException { + capacityChannel.update(Integer.MAX_VALUE); + } + + @Override + public void consume(final ByteBuffer src) throws IOException { + if (src == null || !src.hasRemaining() || shutdown) { + return; + } + final byte[] data = new byte[src.remaining()]; + src.get(data); + inbound.offer(data); + } + + @Override + public void streamEnd(final List trailers) throws HttpException, IOException { + inbound.offer(END_INBOUND); + } + + @Override + public int available() { + if (!responseSent || outboundEnd) { + return 0; + } + final ByteBuffer next; + outLock.lock(); + try { + next = currentOutbound != null ? currentOutbound : outbound.peek(); + } finally { + outLock.unlock(); + } + if (next == null) { + return 0; + } + if (next == END_OUTBOUND) { + // Force produce() so we can emit END_STREAM. + return 1; + } + return next.remaining(); + } + + @Override + public void produce(final DataStreamChannel channel) throws IOException { + if (!responseSent || outboundEnd) { + return; + } + this.dataChannel = channel; + + for (; ; ) { + final ByteBuffer buf; + outLock.lock(); + try { + if (currentOutbound == null) { + currentOutbound = outbound.poll(); + } + buf = currentOutbound; + } finally { + outLock.unlock(); + } + if (buf == null) { + return; + } + + if (buf == END_OUTBOUND) { + outLock.lock(); + try { + currentOutbound = null; + } finally { + outLock.unlock(); + } + outboundEnd = true; + channel.endStream(null); + return; + } + + if (!buf.hasRemaining()) { + outLock.lock(); + try { + currentOutbound = null; + } finally { + outLock.unlock(); + } + continue; + } + + final int n = channel.write(buf); + if (n == 0) { + channel.requestOutput(); + return; + } + if (buf.hasRemaining()) { + channel.requestOutput(); + return; + } + + outLock.lock(); + try { + currentOutbound = null; + } finally { + outLock.unlock(); + } + } + } + + @Override + public void failed(final Exception cause) { + shutdown = true; + outbound.offer(END_OUTBOUND); + inbound.offer(END_INBOUND); + + final DataStreamChannel channel = dataChannel; + if (channel != null) { + channel.requestOutput(); + } + } + + @Override + public void releaseResources() { + shutdown = true; + outbound.clear(); + inbound.clear(); + outLock.lock(); + try { + currentOutbound = null; + } finally { + outLock.unlock(); + } + } + + private static final class QueueInputStream extends InputStream { + + private final BlockingQueue queue; + private byte[] current; + private int pos; + + QueueInputStream(final BlockingQueue queue) { + this.queue = queue; + } + + @Override + public int read() throws IOException { + if (current == null || pos >= current.length) { + try { + current = queue.take(); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException(ex.getMessage(), ex); + } + pos = 0; + if (current == END_INBOUND) { + return -1; + } + } + return current[pos++] & 0xFF; + } + } + + private final class QueueOutputStream extends OutputStream { + + private final BlockingQueue queue; + + QueueOutputStream(final BlockingQueue queue) { + this.queue = queue; + } + + @Override + public void write(final int b) throws IOException { + queue.offer(ByteBuffer.wrap(new byte[]{(byte) b})); + requestOutput(); + } + + @Override + public void write(final byte[] b, final int off, final int len) throws IOException { + if (len == 0) { + return; + } + final byte[] copy = new byte[len]; + System.arraycopy(b, off, copy, 0, len); + queue.offer(ByteBuffer.wrap(copy)); + requestOutput(); + } + + @Override + public void close() { + queue.offer(END_OUTBOUND); + requestOutput(); + } + + private void requestOutput() { + final DataStreamChannel channel = dataChannel; + if (responseSent && channel != null) { + channel.requestOutput(); + } + } + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketHttpService.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketHttpService.java new file mode 100644 index 000000000..3caee56c7 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketHttpService.java @@ -0,0 +1,68 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.io.IOException; + +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.impl.DefaultConnectionReuseStrategy; +import org.apache.hc.core5.http.impl.io.HttpService; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http.io.HttpServerConnection; +import org.apache.hc.core5.http.io.HttpServerRequestHandler; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.ConnectionReuseStrategy; +import org.apache.hc.core5.http.impl.Http1StreamListener; +import org.apache.hc.core5.util.Args; + +class WebSocketHttpService extends HttpService { + + WebSocketHttpService( + final HttpProcessor processor, + final HttpServerRequestHandler requestHandler, + final Http1Config http1Config, + final ConnectionReuseStrategy connReuseStrategy, + final Http1StreamListener streamListener) { + super( + Args.notNull(processor, "HTTP processor"), + Args.notNull(requestHandler, "Request handler"), + http1Config != null ? http1Config : Http1Config.DEFAULT, + connReuseStrategy != null ? connReuseStrategy : DefaultConnectionReuseStrategy.INSTANCE, + streamListener); + } + + @Override + public void handleRequest( + final HttpServerConnection conn, + final HttpContext localContext) throws IOException, HttpException { + if (localContext != null) { + localContext.setAttribute(WebSocketContextKeys.CONNECTION, conn); + } + super.handleRequest(conn, localContext); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServer.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServer.java new file mode 100644 index 000000000..eacb15e11 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServer.java @@ -0,0 +1,61 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.io.IOException; +import java.net.InetAddress; + +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; + +public final class WebSocketServer { + + private final HttpServer httpServer; + + WebSocketServer(final HttpServer httpServer) { + this.httpServer = httpServer; + } + + public void start() throws IOException { + httpServer.start(); + } + + public void stop() { + httpServer.stop(); + } + + public void initiateShutdown() { + httpServer.initiateShutdown(); + } + + public InetAddress getInetAddress() { + return httpServer.getInetAddress(); + } + + public int getLocalPort() { + return httpServer.getLocalPort(); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrap.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrap.java new file mode 100644 index 000000000..8c7001dcb --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrap.java @@ -0,0 +1,250 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Supplier; + +import javax.net.ServerSocketFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocketFactory; + +import org.apache.hc.core5.function.Callback; +import org.apache.hc.core5.http.ConnectionReuseStrategy; +import org.apache.hc.core5.http.ExceptionListener; +import org.apache.hc.core5.http.HttpRequestMapper; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.DefaultConnectionReuseStrategy; +import org.apache.hc.core5.http.impl.Http1StreamListener; +import org.apache.hc.core5.http.impl.HttpProcessors; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.routing.RequestRouter; +import org.apache.hc.core5.http.io.HttpConnectionFactory; +import org.apache.hc.core5.http.io.SocketConfig; +import org.apache.hc.core5.http.io.ssl.DefaultTlsSetupHandler; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http.protocol.UriPatternType; +import org.apache.hc.core5.net.InetAddressUtils; +import org.apache.hc.core5.net.URIAuthority; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketHandler; + +public class WebSocketServerBootstrap { + + private final List>> routeEntries; + private String canonicalHostName; + private int listenerPort; + private InetAddress localAddress; + private SocketConfig socketConfig; + private Http1Config http1Config; + private CharCodingConfig charCodingConfig; + private HttpProcessor httpProcessor; + private ConnectionReuseStrategy connStrategy; + private ServerSocketFactory serverSocketFactory; + private SSLContext sslContext; + private Callback sslSetupHandler; + private ExceptionListener exceptionListener; + private Http1StreamListener streamListener; + private BiFunction authorityResolver; + private HttpRequestMapper> requestRouter; + private WebSocketConfig webSocketConfig; + private HttpConnectionFactory connectionFactory; + private WebSocketExtensionRegistry extensionRegistry; + + private WebSocketServerBootstrap() { + this.routeEntries = new ArrayList<>(); + } + + public static WebSocketServerBootstrap bootstrap() { + return new WebSocketServerBootstrap(); + } + + public WebSocketServerBootstrap setCanonicalHostName(final String canonicalHostName) { + this.canonicalHostName = canonicalHostName; + return this; + } + + public WebSocketServerBootstrap setListenerPort(final int listenerPort) { + this.listenerPort = listenerPort; + return this; + } + + public WebSocketServerBootstrap setLocalAddress(final InetAddress localAddress) { + this.localAddress = localAddress; + return this; + } + + public WebSocketServerBootstrap setSocketConfig(final SocketConfig socketConfig) { + this.socketConfig = socketConfig; + return this; + } + + public WebSocketServerBootstrap setHttp1Config(final Http1Config http1Config) { + this.http1Config = http1Config; + return this; + } + + public WebSocketServerBootstrap setCharCodingConfig(final CharCodingConfig charCodingConfig) { + this.charCodingConfig = charCodingConfig; + return this; + } + + public WebSocketServerBootstrap setHttpProcessor(final HttpProcessor httpProcessor) { + this.httpProcessor = httpProcessor; + return this; + } + + public WebSocketServerBootstrap setConnectionReuseStrategy(final ConnectionReuseStrategy connStrategy) { + this.connStrategy = connStrategy; + return this; + } + + public WebSocketServerBootstrap setServerSocketFactory(final ServerSocketFactory serverSocketFactory) { + this.serverSocketFactory = serverSocketFactory; + return this; + } + + public WebSocketServerBootstrap setSslContext(final SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + + public WebSocketServerBootstrap setSslSetupHandler(final Callback sslSetupHandler) { + this.sslSetupHandler = sslSetupHandler; + return this; + } + + public WebSocketServerBootstrap setExceptionListener(final ExceptionListener exceptionListener) { + this.exceptionListener = exceptionListener; + return this; + } + + public WebSocketServerBootstrap setStreamListener(final Http1StreamListener streamListener) { + this.streamListener = streamListener; + return this; + } + + public WebSocketServerBootstrap setAuthorityResolver(final BiFunction authorityResolver) { + this.authorityResolver = authorityResolver; + return this; + } + + public WebSocketServerBootstrap setRequestRouter(final HttpRequestMapper> requestRouter) { + this.requestRouter = requestRouter; + return this; + } + + public WebSocketServerBootstrap setWebSocketConfig(final WebSocketConfig webSocketConfig) { + this.webSocketConfig = webSocketConfig; + return this; + } + + public WebSocketServerBootstrap setExtensionRegistry(final WebSocketExtensionRegistry extensionRegistry) { + this.extensionRegistry = extensionRegistry; + return this; + } + + public WebSocketServerBootstrap setConnectionFactory( + final HttpConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + return this; + } + + public WebSocketServerBootstrap register(final String uriPattern, final Supplier supplier) { + Args.notNull(uriPattern, "URI pattern"); + Args.notNull(supplier, "WebSocket handler supplier"); + this.routeEntries.add(new RequestRouter.Entry<>(uriPattern, supplier)); + return this; + } + + public WebSocketServerBootstrap register(final String hostname, final String uriPattern, final Supplier supplier) { + Args.notNull(hostname, "Hostname"); + Args.notNull(uriPattern, "URI pattern"); + Args.notNull(supplier, "WebSocket handler supplier"); + this.routeEntries.add(new RequestRouter.Entry<>(hostname, uriPattern, supplier)); + return this; + } + + public WebSocketServer create() { + final String actualCanonicalHostName = canonicalHostName != null ? canonicalHostName : InetAddressUtils.getCanonicalLocalHostName(); + final HttpRequestMapper> requestRouterCopy; + if (routeEntries.isEmpty()) { + requestRouterCopy = requestRouter; + } else { + requestRouterCopy = RequestRouter.create( + new URIAuthority(actualCanonicalHostName), + UriPatternType.URI_PATTERN, + routeEntries, + authorityResolver != null ? authorityResolver : RequestRouter.IGNORE_PORT_AUTHORITY_RESOLVER, + requestRouter); + } + final HttpRequestMapper> router = requestRouterCopy != null ? requestRouterCopy : (r, c) -> null; + + final WebSocketExtensionRegistry extensions = extensionRegistry != null + ? extensionRegistry + : WebSocketExtensionRegistry.createDefault(); + final WebSocketServerRequestHandler requestHandler = new WebSocketServerRequestHandler( + router, + webSocketConfig != null ? webSocketConfig : WebSocketConfig.DEFAULT, + extensions); + + final HttpProcessor processor = httpProcessor != null ? httpProcessor : HttpProcessors.server(); + final WebSocketHttpService httpService = new WebSocketHttpService( + processor, + requestHandler, + http1Config, + connStrategy != null ? connStrategy : DefaultConnectionReuseStrategy.INSTANCE, + streamListener); + + HttpConnectionFactory connectionFactoryCopy = this.connectionFactory; + if (connectionFactoryCopy == null) { + final String scheme = serverSocketFactory instanceof SSLServerSocketFactory || sslContext != null ? + URIScheme.HTTPS.id : URIScheme.HTTP.id; + connectionFactoryCopy = new WebSocketServerConnectionFactory(scheme, http1Config, charCodingConfig); + } + + final HttpServer httpServer = new HttpServer( + Math.max(this.listenerPort, 0), + httpService, + this.localAddress, + this.socketConfig != null ? this.socketConfig : SocketConfig.DEFAULT, + serverSocketFactory, + connectionFactoryCopy, + sslContext, + sslSetupHandler != null ? sslSetupHandler : DefaultTlsSetupHandler.SERVER, + this.exceptionListener != null ? this.exceptionListener : ExceptionListener.NO_OP); + return new WebSocketServer(httpServer); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnection.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnection.java new file mode 100644 index 000000000..ac0cc48ae --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnection.java @@ -0,0 +1,68 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CharsetEncoder; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentLengthStrategy; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.io.DefaultBHttpServerConnection; +import org.apache.hc.core5.http.impl.io.SocketHolder; +import org.apache.hc.core5.http.io.HttpMessageParserFactory; +import org.apache.hc.core5.http.io.HttpMessageWriterFactory; + +class WebSocketServerConnection extends DefaultBHttpServerConnection { + + WebSocketServerConnection( + final String scheme, + final Http1Config http1Config, + final CharsetDecoder charDecoder, + final CharsetEncoder charEncoder, + final ContentLengthStrategy incomingContentStrategy, + final ContentLengthStrategy outgoingContentStrategy, + final HttpMessageParserFactory requestParserFactory, + final HttpMessageWriterFactory responseWriterFactory) { + super(scheme, http1Config, charDecoder, charEncoder, incomingContentStrategy, outgoingContentStrategy, + requestParserFactory, responseWriterFactory); + } + + InputStream getSocketInputStream() throws IOException { + final SocketHolder socketHolder = getSocketHolder(); + return socketHolder != null ? socketHolder.getInputStream() : null; + } + + OutputStream getSocketOutputStream() throws IOException { + final SocketHolder socketHolder = getSocketHolder(); + return socketHolder != null ? socketHolder.getOutputStream() : null; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactory.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactory.java new file mode 100644 index 000000000..51e466b6e --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactory.java @@ -0,0 +1,105 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.io.IOException; +import java.net.Socket; + +import javax.net.ssl.SSLSocket; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentLengthStrategy; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.CharCodingSupport; +import org.apache.hc.core5.http.impl.io.DefaultHttpRequestParserFactory; +import org.apache.hc.core5.http.impl.io.DefaultHttpResponseWriterFactory; +import org.apache.hc.core5.http.io.HttpConnectionFactory; +import org.apache.hc.core5.http.io.HttpMessageParserFactory; +import org.apache.hc.core5.http.io.HttpMessageWriterFactory; + +class WebSocketServerConnectionFactory implements HttpConnectionFactory { + + private final String scheme; + private final Http1Config http1Config; + private final CharCodingConfig charCodingConfig; + private final ContentLengthStrategy incomingContentStrategy; + private final ContentLengthStrategy outgoingContentStrategy; + private final HttpMessageParserFactory requestParserFactory; + private final HttpMessageWriterFactory responseWriterFactory; + + WebSocketServerConnectionFactory( + final String scheme, + final Http1Config http1Config, + final CharCodingConfig charCodingConfig, + final ContentLengthStrategy incomingContentStrategy, + final ContentLengthStrategy outgoingContentStrategy, + final HttpMessageParserFactory requestParserFactory, + final HttpMessageWriterFactory responseWriterFactory) { + this.scheme = scheme; + this.http1Config = http1Config != null ? http1Config : Http1Config.DEFAULT; + this.charCodingConfig = charCodingConfig != null ? charCodingConfig : CharCodingConfig.DEFAULT; + this.incomingContentStrategy = incomingContentStrategy; + this.outgoingContentStrategy = outgoingContentStrategy; + this.requestParserFactory = requestParserFactory != null ? requestParserFactory : + new DefaultHttpRequestParserFactory(this.http1Config); + this.responseWriterFactory = responseWriterFactory != null ? responseWriterFactory : + new DefaultHttpResponseWriterFactory(this.http1Config); + } + + WebSocketServerConnectionFactory(final String scheme, final Http1Config http1Config, final CharCodingConfig charCodingConfig) { + this(scheme, http1Config, charCodingConfig, null, null, null, null); + } + + private WebSocketServerConnection createDetached(final Socket socket) { + return new WebSocketServerConnection( + scheme != null ? scheme : (socket instanceof SSLSocket ? URIScheme.HTTPS.id : URIScheme.HTTP.id), + this.http1Config, + CharCodingSupport.createDecoder(this.charCodingConfig), + CharCodingSupport.createEncoder(this.charCodingConfig), + this.incomingContentStrategy, + this.outgoingContentStrategy, + this.requestParserFactory, + this.responseWriterFactory); + } + + @Override + public WebSocketServerConnection createConnection(final Socket socket) throws IOException { + final WebSocketServerConnection conn = createDetached(socket); + conn.bind(socket); + return conn; + } + + @Override + public WebSocketServerConnection createConnection(final SSLSocket sslSocket, final Socket socket) throws IOException { + final WebSocketServerConnection conn = createDetached(sslSocket); + conn.bind(sslSocket, socket); + return conn; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessor.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessor.java new file mode 100644 index 000000000..7208bb3a6 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessor.java @@ -0,0 +1,163 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.WebSocketCloseStatus; +import org.apache.hc.core5.websocket.WebSocketException; +import org.apache.hc.core5.websocket.WebSocketFrame; +import org.apache.hc.core5.websocket.WebSocketFrameType; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.message.CloseCodec; + +class WebSocketServerProcessor { + + private final WebSocketSession session; + private final WebSocketHandler handler; + private final int maxMessageSize; + + WebSocketServerProcessor(final WebSocketSession session, final WebSocketHandler handler, final int maxMessageSize) { + this.session = Args.notNull(session, "WebSocket session"); + this.handler = Args.notNull(handler, "WebSocket handler"); + this.maxMessageSize = maxMessageSize; + } + + void process() throws IOException { + ByteArrayOutputStream continuationBuffer = null; + WebSocketFrameType continuationType = null; + while (true) { + final WebSocketFrame frame = session.readFrame(); + if (frame == null) { + break; + } + if (frame.isRsv2() || frame.isRsv3()) { + throw new WebSocketException("Unsupported RSV bits"); + } + final WebSocketFrameType type = frame.getType(); + final int payloadLen = frame.getPayload().remaining(); + if (type == WebSocketFrameType.CLOSE + || type == WebSocketFrameType.PING + || type == WebSocketFrameType.PONG) { + if (!frame.isFin() || payloadLen > 125) { + throw new WebSocketException("Invalid control frame"); + } + } + switch (type) { + case PING: + handler.onPing(session, frame.getPayload()); + session.sendPong(frame.getPayload()); + break; + case PONG: + handler.onPong(session, frame.getPayload()); + break; + case CLOSE: + handleCloseFrame(frame); + return; + case TEXT: + case BINARY: + if (frame.isFin()) { + dispatchMessage(type, frame.getPayload()); + } else { + continuationBuffer = startContinuation(type, frame.getPayload()); + continuationType = type; + } + break; + case CONTINUATION: + if (continuationBuffer == null || continuationType == null) { + throw new WebSocketException("Unexpected continuation frame"); + } + appendContinuation(continuationBuffer, frame.getPayload()); + if (frame.isFin()) { + final ByteBuffer payload = ByteBuffer.wrap(continuationBuffer.toByteArray()); + dispatchMessage(continuationType, payload); + continuationBuffer = null; + continuationType = null; + } + break; + default: + throw new WebSocketException("Unsupported frame type: " + type); + } + } + } + + private void dispatchMessage(final WebSocketFrameType type, final ByteBuffer payload) throws IOException { + if (payload.remaining() > maxMessageSize) { + throw new WebSocketProtocolException(1009, "Message too large: " + payload.remaining()); + } + if (type == WebSocketFrameType.TEXT) { + handler.onText(session, WebSocketSession.decodeText(payload)); + } else { + handler.onBinary(session, payload); + } + } + + private ByteArrayOutputStream startContinuation(final WebSocketFrameType type, final ByteBuffer payload) throws WebSocketException { + if (payload.remaining() > maxMessageSize) { + throw new WebSocketProtocolException(1009, "Message too large: " + payload.remaining()); + } + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(payload.remaining()); + appendContinuation(buffer, payload); + return buffer; + } + + private void appendContinuation(final ByteArrayOutputStream buffer, final ByteBuffer payload) throws WebSocketException { + if (buffer.size() + payload.remaining() > maxMessageSize) { + throw new WebSocketProtocolException(1009, "Message too large: " + (buffer.size() + payload.remaining())); + } + final ByteBuffer copy = payload.asReadOnlyBuffer(); + final byte[] data = new byte[copy.remaining()]; + copy.get(data); + buffer.write(data, 0, data.length); + } + + private void handleCloseFrame(final WebSocketFrame frame) throws IOException { + final ByteBuffer payload = frame.getPayload(); + final int remaining = payload.remaining(); + int statusCode = WebSocketCloseStatus.NORMAL.getCode(); + String reason = ""; + if (remaining == 1) { + throw new WebSocketProtocolException(1002, "Invalid close payload length"); + } else if (remaining >= 2) { + final int code = ((payload.get() & 0xFF) << 8) | (payload.get() & 0xFF); + if (!CloseCodec.isValidToReceive(code)) { + throw new WebSocketProtocolException(1002, "Invalid close code: " + code); + } + statusCode = code; + if (payload.hasRemaining()) { + reason = WebSocketSession.decodeText(payload); + } + } + handler.onClose(session, statusCode, reason); + session.close(statusCode, reason); + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandler.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandler.java new file mode 100644 index 000000000..4a7adb556 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandler.java @@ -0,0 +1,145 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.function.Supplier; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HeaderElements; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequestMapper; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.HttpServerRequestHandler; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.WebSocketCloseStatus; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketConstants; +import org.apache.hc.core5.websocket.WebSocketException; +import org.apache.hc.core5.websocket.WebSocketExtensionNegotiation; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketExtensions; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketHandshake; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; + +class WebSocketServerRequestHandler implements HttpServerRequestHandler { + + private final HttpRequestMapper> requestMapper; + private final WebSocketConfig config; + private final WebSocketExtensionRegistry extensionRegistry; + + WebSocketServerRequestHandler( + final HttpRequestMapper> requestMapper, + final WebSocketConfig config, + final WebSocketExtensionRegistry extensionRegistry) { + this.requestMapper = Args.notNull(requestMapper, "Request mapper"); + this.config = config != null ? config : WebSocketConfig.DEFAULT; + this.extensionRegistry = extensionRegistry != null ? extensionRegistry : new WebSocketExtensionRegistry(); + } + + @Override + public void handle( + final ClassicHttpRequest request, + final ResponseTrigger trigger, + final HttpContext context) throws HttpException, IOException { + final Supplier supplier = requestMapper.resolve(request, context); + if (supplier == null) { + trigger.submitResponse(new BasicClassicHttpResponse(HttpStatus.SC_NOT_FOUND)); + return; + } + if (!WebSocketHandshake.isWebSocketUpgrade(request)) { + trigger.submitResponse(new BasicClassicHttpResponse(HttpStatus.SC_UPGRADE_REQUIRED)); + return; + } + final WebSocketHandler handler = supplier.get(); + final WebSocketServerConnection connection = getConnection(context); + if (connection == null) { + trigger.submitResponse(new BasicClassicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR)); + return; + } + final String key = request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_KEY).getValue(); + final String accept = WebSocketHandshake.createAcceptKey(key); + final ClassicHttpResponse response = new BasicClassicHttpResponse(HttpStatus.SC_SWITCHING_PROTOCOLS); + response.addHeader(HttpHeaders.CONNECTION, HeaderElements.UPGRADE); + response.addHeader(HttpHeaders.UPGRADE, "websocket"); + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_ACCEPT, accept); + final WebSocketExtensionNegotiation negotiation = extensionRegistry.negotiate( + WebSocketExtensions.parse(request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS)), + true); + final String extensionsHeader = negotiation.formatResponseHeader(); + if (extensionsHeader != null) { + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS, extensionsHeader); + } + final List offeredProtocols = WebSocketHandshake.parseSubprotocols( + request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL)); + final String protocol = handler.selectSubprotocol(offeredProtocols); + if (protocol != null) { + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL, protocol); + } + trigger.submitResponse(response); + final InputStream inputStream = connection.getSocketInputStream(); + final OutputStream outputStream = connection.getSocketOutputStream(); + if (inputStream == null || outputStream == null) { + connection.close(); + return; + } + final WebSocketSession session = new WebSocketSession(config, inputStream, outputStream, + connection.getRemoteAddress(), connection.getLocalAddress(), negotiation.getExtensions()); + try { + handler.onOpen(session); + new WebSocketServerProcessor(session, handler, config.getMaxMessageSize()).process(); + } catch (final WebSocketProtocolException ex) { + handler.onError(session, ex); + session.close(ex.closeCode, ex.getMessage()); + } catch (final WebSocketException ex) { + handler.onError(session, ex); + session.close(WebSocketCloseStatus.PROTOCOL_ERROR.getCode(), ex.getMessage()); + } catch (final Exception ex) { + handler.onError(session, ex); + session.close(WebSocketCloseStatus.INTERNAL_ERROR.getCode(), "WebSocket error"); + } finally { + connection.close(); + } + } + + private static WebSocketServerConnection getConnection(final HttpContext context) { + if (context == null) { + return null; + } + final Object conn = context.getAttribute(WebSocketContextKeys.CONNECTION); + return conn instanceof WebSocketServerConnection ? (WebSocketServerConnection) conn : null; + } +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/util/ByteBufferPool.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/util/ByteBufferPool.java new file mode 100644 index 000000000..b422560f0 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/util/ByteBufferPool.java @@ -0,0 +1,127 @@ +/* + * ==================================================================== + * 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.core5.websocket.util; + +import java.nio.ByteBuffer; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Lock-free fixed-size ByteBuffer pool with a hard capacity limit. + * Buffers are cleared before reuse. Non-matching capacities are dropped. + * + * @since 5.7 + */ +@Internal +public final class ByteBufferPool { + + private final ConcurrentLinkedQueue pool = new ConcurrentLinkedQueue<>(); + private final AtomicInteger pooled = new AtomicInteger(0); + + private final int bufferSize; + private final int maxCapacity; + private final boolean direct; + + public ByteBufferPool(final int bufferSize, final int maxCapacity) { + this(bufferSize, maxCapacity, false); + } + + public ByteBufferPool(final int bufferSize, final int maxCapacity, final boolean direct) { + if (bufferSize <= 0 || maxCapacity < 0) { + throw new IllegalArgumentException("Invalid pool configuration"); + } + this.bufferSize = bufferSize; + this.maxCapacity = maxCapacity; + this.direct = direct; + } + + /** + * Acquire a buffer or allocate a new one if the pool is empty. + */ + public ByteBuffer acquire() { + final ByteBuffer buf = pool.poll(); + if (buf != null) { + pooled.decrementAndGet(); + buf.clear(); + return buf; + } + return direct ? ByteBuffer.allocateDirect(bufferSize) : ByteBuffer.allocate(bufferSize); + } + + /** + * Return a buffer to the pool iff it matches the configured capacity and there is room. + */ + public void release(final ByteBuffer buffer) { + if (buffer == null || buffer.capacity() != bufferSize) { + return; + } + buffer.clear(); + for (; ; ) { + final int n = pooled.get(); + if (n >= maxCapacity) { + return; + } + if (pooled.compareAndSet(n, n + 1)) { + pool.offer(buffer); + return; + } + } + } + + /** + * Drain the pool. + */ + public void clear() { + while (pool.poll() != null) { /* drain */ } + pooled.set(0); + } + + /** + * Size in bytes of pooled buffers. + */ + public int bufferSize() { + return bufferSize; + } + + /** + * Backwards-compatible accessor for callers expecting getBufferSize(). + */ + public int getBufferSize() { + return bufferSize; + } + + public int maxCapacity() { + return maxCapacity; + } + + public int pooledCount() { + return pooled.get(); + } + +} diff --git a/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/util/package-info.java b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/util/package-info.java new file mode 100644 index 000000000..1b653a688 --- /dev/null +++ b/httpcore5-websocket/src/main/java/org/apache/hc/core5/websocket/util/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +/** + * Utility helpers for WebSocket internals. + * + *

Includes buffer pooling and lightweight helpers that are not part of + * the public API.

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket.util; diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactoryTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactoryTest.java new file mode 100644 index 000000000..964ed201c --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactoryTest.java @@ -0,0 +1,72 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +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 java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class PerMessageDeflateExtensionFactoryTest { + + @Test + void createsExtension() { + final PerMessageDeflateExtensionFactory factory = new PerMessageDeflateExtensionFactory(); + assertEquals("permessage-deflate", factory.getName()); + final WebSocketExtension ext = factory.create( + new WebSocketExtensionData("permessage-deflate", Collections.emptyMap()), true); + assertNotNull(ext); + assertEquals("permessage-deflate", ext.getName()); + } + + @Test + void rejectsUnsupportedWindowBits() { + final Map params = new LinkedHashMap<>(); + params.put("client_max_window_bits", "12"); + final WebSocketExtension ext = new PerMessageDeflateExtensionFactory().create( + new WebSocketExtensionData("permessage-deflate", params), true); + assertNull(ext); + } + + @Test + void echoesNegotiatedWindowBitsWhenRequested() { + final Map params = new LinkedHashMap<>(); + params.put("client_max_window_bits", "15"); + params.put("server_max_window_bits", "15"); + final WebSocketExtension ext = new PerMessageDeflateExtensionFactory().create( + new WebSocketExtensionData("permessage-deflate", params), true); + assertNotNull(ext); + final WebSocketExtensionData response = ext.getResponseData(); + assertEquals("15", response.getParameters().get("client_max_window_bits")); + assertEquals("15", response.getParameters().get("server_max_window_bits")); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionTest.java new file mode 100644 index 000000000..3e1fcc399 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionTest.java @@ -0,0 +1,88 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.zip.Deflater; + +import org.junit.jupiter.api.Test; + +class PerMessageDeflateExtensionTest { + + @Test + void decodesFragmentedMessage() throws Exception { + final byte[] plain = "fragmented message".getBytes(StandardCharsets.UTF_8); + final byte[] compressed = deflateWithSyncFlush(plain); + final int mid = compressed.length / 2; + final ByteBuffer part1 = ByteBuffer.wrap(compressed, 0, mid); + final ByteBuffer part2 = ByteBuffer.wrap(compressed, mid, compressed.length - mid); + + final PerMessageDeflateExtension ext = new PerMessageDeflateExtension(); + final ByteBuffer out1 = ext.decode(WebSocketFrameType.TEXT, false, part1); + final ByteBuffer out2 = ext.decode(WebSocketFrameType.CONTINUATION, true, part2); + + final ByteArrayOutputStream joined = new ByteArrayOutputStream(); + joined.write(toBytes(out1)); + joined.write(toBytes(out2)); + + assertEquals("fragmented message", WebSocketSession.decodeText(ByteBuffer.wrap(joined.toByteArray()))); + } + + private static byte[] deflateWithSyncFlush(final byte[] input) { + final Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true); + deflater.setInput(input); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, input.length / 2)); + final byte[] buffer = new byte[8192]; + while (!deflater.needsInput()) { + final int count = deflater.deflate(buffer, 0, buffer.length, Deflater.SYNC_FLUSH); + if (count > 0) { + out.write(buffer, 0, count); + } else { + break; + } + } + deflater.end(); + final byte[] data = out.toByteArray(); + if (data.length >= 4) { + final byte[] trimmed = new byte[data.length - 4]; + System.arraycopy(data, 0, trimmed, 0, trimmed.length); + return trimmed; + } + return data; + } + + private static byte[] toBytes(final ByteBuffer buf) { + final ByteBuffer copy = buf.asReadOnlyBuffer(); + final byte[] out = new byte[copy.remaining()]; + copy.get(out); + return out; + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketCloseStatusTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketCloseStatusTest.java new file mode 100644 index 000000000..cb58d6322 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketCloseStatusTest.java @@ -0,0 +1,41 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class WebSocketCloseStatusTest { + + @Test + void exposesStatusCodes() { + assertEquals(1000, WebSocketCloseStatus.NORMAL.getCode()); + assertEquals(1002, WebSocketCloseStatus.PROTOCOL_ERROR.getCode()); + assertEquals(1011, WebSocketCloseStatus.INTERNAL_ERROR.getCode()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConfigTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConfigTest.java new file mode 100644 index 000000000..b8a021c48 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConfigTest.java @@ -0,0 +1,60 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class WebSocketConfigTest { + + @Test + void defaultsArePositive() { + final WebSocketConfig cfg = WebSocketConfig.DEFAULT; + assertEquals(16 * 1024 * 1024, cfg.getMaxFramePayloadSize()); + assertEquals(64 * 1024 * 1024, cfg.getMaxMessageSize()); + } + + @Test + void customBuilderAppliesLimits() { + final WebSocketConfig cfg = WebSocketConfig.custom() + .setMaxFramePayloadSize(1024) + .setMaxMessageSize(2048) + .build(); + assertEquals(1024, cfg.getMaxFramePayloadSize()); + assertEquals(2048, cfg.getMaxMessageSize()); + } + + @Test + void invalidSizesThrow() { + assertThrows(IllegalArgumentException.class, + () -> WebSocketConfig.custom().setMaxFramePayloadSize(0)); + assertThrows(IllegalArgumentException.class, + () -> WebSocketConfig.custom().setMaxMessageSize(0)); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConstantsTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConstantsTest.java new file mode 100644 index 000000000..cd8130e86 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConstantsTest.java @@ -0,0 +1,44 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class WebSocketConstantsTest { + + @Test + void exposesStandardHeaderNames() { + assertEquals("Sec-WebSocket-Key", WebSocketConstants.SEC_WEBSOCKET_KEY); + assertEquals("Sec-WebSocket-Version", WebSocketConstants.SEC_WEBSOCKET_VERSION); + assertEquals("Sec-WebSocket-Accept", WebSocketConstants.SEC_WEBSOCKET_ACCEPT); + assertEquals("Sec-WebSocket-Protocol", WebSocketConstants.SEC_WEBSOCKET_PROTOCOL); + assertEquals("Sec-WebSocket-Extensions", WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS); + assertEquals("258EAFA5-E914-47DA-95CA-C5AB0DC85B11", WebSocketConstants.WEBSOCKET_GUID); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExceptionTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExceptionTest.java new file mode 100644 index 000000000..75a676b14 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExceptionTest.java @@ -0,0 +1,43 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; + +class WebSocketExceptionTest { + + @Test + void carriesMessageAndCause() { + final RuntimeException cause = new RuntimeException("boom"); + final WebSocketException ex = new WebSocketException("fail", cause); + assertEquals("fail", ex.getMessage()); + assertSame(cause, ex.getCause()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionDataTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionDataTest.java new file mode 100644 index 000000000..7b61e2133 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionDataTest.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class WebSocketExtensionDataTest { + + @Test + void formatsParametersInOrder() { + final Map params = new LinkedHashMap<>(); + params.put("server_no_context_takeover", null); + params.put("client_max_window_bits", "12"); + final WebSocketExtensionData data = new WebSocketExtensionData("permessage-deflate", params); + + assertEquals("permessage-deflate", data.getName()); + assertTrue(data.getParameters().containsKey("server_no_context_takeover")); + assertEquals("permessage-deflate; server_no_context_takeover; client_max_window_bits=12", data.format()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiationTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiationTest.java new file mode 100644 index 000000000..c1c5a415d --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiationTest.java @@ -0,0 +1,54 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +class WebSocketExtensionNegotiationTest { + + @Test + void formatsResponseHeader() { + final WebSocketExtensionData data = new WebSocketExtensionData( + "permessage-deflate", Collections.singletonMap("client_max_window_bits", "12")); + final WebSocketExtensionNegotiation negotiation = new WebSocketExtensionNegotiation( + Collections.emptyList(), + Collections.singletonList(data)); + assertEquals("permessage-deflate; client_max_window_bits=12", negotiation.formatResponseHeader()); + } + + @Test + void formatsEmptyHeaderAsNull() { + final WebSocketExtensionNegotiation negotiation = new WebSocketExtensionNegotiation( + Collections.emptyList(), Collections.emptyList()); + assertNull(negotiation.formatResponseHeader()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistryTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistryTest.java new file mode 100644 index 000000000..f3b6b7418 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistryTest.java @@ -0,0 +1,105 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.nio.ByteBuffer; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +class WebSocketExtensionRegistryTest { + + @Test + void registersAndNegotiatesExtensions() throws Exception { + final WebSocketExtensionRegistry registry = new WebSocketExtensionRegistry() + .register(new WebSocketExtensionFactory() { + @Override + public String getName() { + return "x-test"; + } + + @Override + public WebSocketExtension create(final WebSocketExtensionData request, final boolean server) { + return new WebSocketExtension() { + @Override + public String getName() { + return "x-test"; + } + + @Override + public boolean usesRsv1() { + return false; + } + + @Override + public boolean usesRsv2() { + return false; + } + + @Override + public boolean usesRsv3() { + return false; + } + + @Override + public ByteBuffer decode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) { + return payload; + } + + @Override + public ByteBuffer encode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) { + return payload; + } + + @Override + public WebSocketExtensionData getResponseData() { + return new WebSocketExtensionData("x-test", Collections.emptyMap()); + } + }; + } + }); + + final WebSocketExtensionNegotiation negotiation = registry.negotiate( + Collections.singletonList(new WebSocketExtensionData("x-test", Collections.emptyMap())), + true); + assertNotNull(negotiation); + assertEquals(1, negotiation.getExtensions().size()); + assertEquals("x-test", negotiation.getResponseData().get(0).getName()); + } + + @Test + void defaultRegistryContainsPerMessageDeflate() throws Exception { + final WebSocketExtensionRegistry registry = WebSocketExtensionRegistry.createDefault(); + final WebSocketExtensionNegotiation negotiation = registry.negotiate( + Collections.singletonList(new WebSocketExtensionData("permessage-deflate", Collections.emptyMap())), + true); + assertEquals(1, negotiation.getExtensions().size()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionsTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionsTest.java new file mode 100644 index 000000000..5b48cae8f --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionsTest.java @@ -0,0 +1,52 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.http.message.BasicHeader; +import org.junit.jupiter.api.Test; + +class WebSocketExtensionsTest { + + @Test + void parsesExtensionsHeader() { + final BasicHeader header = new BasicHeader( + WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS, + "permessage-deflate; client_max_window_bits=12, foo; bar=1"); + final List data = WebSocketExtensions.parse(header); + assertEquals(2, data.size()); + assertEquals("permessage-deflate", data.get(0).getName()); + assertEquals("12", data.get(0).getParameters().get("client_max_window_bits")); + assertEquals("foo", data.get(1).getName()); + final Map params = data.get(1).getParameters(); + assertEquals("1", params.get("bar")); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameCodecTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameCodecTest.java new file mode 100644 index 000000000..762f27c2c --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameCodecTest.java @@ -0,0 +1,84 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketFrameCodecTest { + + @Test + void readsMaskedTextFrame() throws Exception { + final byte[] payload = "hi".getBytes(StandardCharsets.UTF_8); + final byte[] mask = new byte[] { 1, 2, 3, 4 }; + final byte[] masked = new byte[payload.length]; + for (int i = 0; i < payload.length; i++) { + masked[i] = (byte) (payload[i] ^ mask[i % 4]); + } + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x81); + out.write(0x80 | payload.length); + out.write(mask); + out.write(masked); + final WebSocketFrameReader reader = new WebSocketFrameReader( + WebSocketConfig.DEFAULT, + new ByteArrayInputStream(out.toByteArray()), + Collections.emptyList()); + final WebSocketFrame frame = reader.readFrame(); + Assertions.assertNotNull(frame); + Assertions.assertEquals(WebSocketFrameType.TEXT, frame.getType()); + Assertions.assertEquals("hi", WebSocketSession.decodeText(frame.getPayload())); + } + + @Test + void writesUnmaskedServerFrame() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final WebSocketFrameWriter writer = new WebSocketFrameWriter(out, Collections.emptyList()); + writer.writeBinary(ByteBuffer.wrap(new byte[] { 1, 2, 3 })); + final byte[] data = out.toByteArray(); + Assertions.assertEquals((byte) 0x82, data[0]); + Assertions.assertEquals((byte) 0x03, data[1]); + Assertions.assertEquals((byte) 1, data[2]); + Assertions.assertEquals((byte) 2, data[3]); + Assertions.assertEquals((byte) 3, data[4]); + } + + @Test + void encodesAndDecodesPerMessageDeflate() throws Exception { + final PerMessageDeflateExtension extension = new PerMessageDeflateExtension(); + final ByteBuffer payload = ByteBuffer.wrap("compress me".getBytes(StandardCharsets.UTF_8)); + final ByteBuffer encoded = extension.encode(WebSocketFrameType.TEXT, true, payload); + final ByteBuffer decoded = extension.decode(WebSocketFrameType.TEXT, true, encoded); + Assertions.assertEquals("compress me", WebSocketSession.decodeText(decoded)); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameReaderTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameReaderTest.java new file mode 100644 index 000000000..c70921bbe --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameReaderTest.java @@ -0,0 +1,163 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +class WebSocketFrameReaderTest { + + private static final byte[] MASK_KEY = new byte[] { 0x11, 0x22, 0x33, 0x44 }; + + private static WebSocketFrame readFrame(final ByteBuffer frame, final int maxFrameSize) throws IOException { + final ByteBuffer copy = frame.asReadOnlyBuffer(); + final byte[] data = new byte[copy.remaining()]; + copy.get(data); + final WebSocketFrameReader reader = new WebSocketFrameReader( + WebSocketConfig.custom().setMaxFramePayloadSize(maxFrameSize).build(), + new ByteArrayInputStream(data), + Collections.emptyList()); + return reader.readFrame(); + } + + private static ByteBuffer maskedFrame(final int firstByte, final byte[] payload) { + final int len = payload.length; + final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8; + final ByteBuffer buf = ByteBuffer.allocate(2 + hdrExtra + 4 + len); + buf.put((byte) firstByte); + if (len <= 125) { + buf.put((byte) (0x80 | len)); + } else if (len <= 0xFFFF) { + buf.put((byte) (0x80 | 126)); + buf.putShort((short) len); + } else { + buf.put((byte) (0x80 | 127)); + buf.putLong(len); + } + buf.put(MASK_KEY); + for (int i = 0; i < len; i++) { + buf.put((byte) (payload[i] ^ MASK_KEY[i % 4])); + } + buf.flip(); + return buf; + } + + private static ByteBuffer unmaskedFrame(final int firstByte, final byte[] payload) { + final int len = payload.length; + final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8; + final ByteBuffer buf = ByteBuffer.allocate(2 + hdrExtra + len); + buf.put((byte) firstByte); + if (len <= 125) { + buf.put((byte) len); + } else if (len <= 0xFFFF) { + buf.put((byte) 126); + buf.putShort((short) len); + } else { + buf.put((byte) 127); + buf.putLong(len); + } + buf.put(payload); + buf.flip(); + return buf; + } + + @Test + void decode_small_text_masked() throws Exception { + final byte[] p = "hello".getBytes(StandardCharsets.UTF_8); + final ByteBuffer f = maskedFrame(0x81, p); // FIN|TEXT + final WebSocketFrame frame = readFrame(f, 8192); + assertNotNull(frame); + assertEquals(WebSocketFrameType.TEXT, frame.getType()); + assertEquals("hello", StandardCharsets.UTF_8.decode(frame.getPayload()).toString()); + } + + @Test + void decode_extended_126_length() throws Exception { + final byte[] p = new byte[300]; + for (int i = 0; i < p.length; i++) { + p[i] = (byte) (i & 0xFF); + } + final ByteBuffer f = maskedFrame(0x82, p); // FIN|BINARY + final WebSocketFrame frame = readFrame(f, 4096); + assertNotNull(frame); + assertEquals(WebSocketFrameType.BINARY, frame.getType()); + final ByteBuffer payload = frame.getPayload(); + final byte[] got = new byte[p.length]; + payload.get(got); + assertArrayEquals(p, got); + } + + @Test + void decode_extended_127_length() throws Exception { + final int len = 66000; + final byte[] p = new byte[len]; + Arrays.fill(p, (byte) 0xAB); + final ByteBuffer f = maskedFrame(0x82, p); // FIN|BINARY + final WebSocketFrame frame = readFrame(f, len + 64); + assertNotNull(frame); + assertEquals(len, frame.getPayload().remaining()); + } + + @Test + void unmasked_client_frame_is_rejected() { + final ByteBuffer f = unmaskedFrame(0x81, new byte[0]); // FIN|TEXT, no MASK + assertThrows(WebSocketException.class, () -> readFrame(f, 1024)); + } + + @Test + void rsv_bits_without_extension_is_rejected() { + final ByteBuffer f = maskedFrame(0xC1, new byte[0]); // FIN|RSV1|TEXT + assertThrows(WebSocketException.class, () -> readFrame(f, 1024)); + } + + @Test + void truncated_frame_throws() { + final ByteBuffer f = ByteBuffer.allocate(2); + f.put((byte) 0x81); + f.put((byte) 0xFE); // MASK|126, but missing extended length and mask + f.flip(); + assertThrows(IOException.class, () -> readFrame(f, 1024)); + } + + @Test + void frame_too_large_throws() { + final int len = 2000; + final ByteBuffer f = maskedFrame(0x82, new byte[len]); + assertThrows(WebSocketException.class, () -> readFrame(f, 1024)); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTest.java new file mode 100644 index 000000000..27cf1c242 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTest.java @@ -0,0 +1,52 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +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.assertTrue; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.Test; + +class WebSocketFrameTest { + + @Test + void exposesImmutablePayload() { + final ByteBuffer payload = ByteBuffer.wrap(new byte[] { 1, 2, 3 }); + final WebSocketFrame frame = new WebSocketFrame(true, false, false, false, WebSocketFrameType.BINARY, payload); + final ByteBuffer got = frame.getPayload(); + + assertTrue(frame.isFin()); + assertFalse(frame.isRsv1()); + assertEquals(WebSocketFrameType.BINARY, frame.getType()); + assertNotSame(payload, got); + assertEquals(3, got.remaining()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTypeTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTypeTest.java new file mode 100644 index 000000000..a46507700 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTypeTest.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class WebSocketFrameTypeTest { + + @Test + void resolvesOpcodes() { + assertEquals(WebSocketFrameType.TEXT, WebSocketFrameType.fromOpcode(0x1)); + assertEquals(WebSocketFrameType.BINARY, WebSocketFrameType.fromOpcode(0x2)); + assertEquals(WebSocketFrameType.PING, WebSocketFrameType.fromOpcode(0x9)); + assertNull(WebSocketFrameType.fromOpcode(0x3)); + } + + @Test + void identifiesControlFrames() { + assertTrue(WebSocketFrameType.PING.isControl()); + assertTrue(WebSocketFrameType.CLOSE.isControl()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketHandshakeTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketHandshakeTest.java new file mode 100644 index 000000000..8f517d259 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketHandshakeTest.java @@ -0,0 +1,60 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.message.BasicClassicHttpRequest; +import org.apache.hc.core5.http.message.BasicHeader; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketHandshakeTest { + + @Test + void createsExpectedAcceptKey() throws WebSocketException { + final String key = "dGhlIHNhbXBsZSBub25jZQ=="; + final String expected = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + Assertions.assertEquals(expected, WebSocketHandshake.createAcceptKey(key)); + } + + @Test + void detectsWebSocketUpgradeRequest() { + final BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "/chat"); + request.addHeader(HttpHeaders.CONNECTION, "Upgrade"); + request.addHeader(HttpHeaders.UPGRADE, "websocket"); + request.addHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION, "13"); + request.addHeader(WebSocketConstants.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ=="); + + Assertions.assertTrue(WebSocketHandshake.isWebSocketUpgrade(request)); + } + + @Test + void parsesSubprotocols() { + final BasicHeader header = new BasicHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL, "chat, superchat"); + Assertions.assertEquals(2, WebSocketHandshake.parseSubprotocols(header).size()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketSessionTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketSessionTest.java new file mode 100644 index 000000000..513b3a27f --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketSessionTest.java @@ -0,0 +1,70 @@ +/* + * ==================================================================== + * 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.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +class WebSocketSessionTest { + + @Test + void writesTextAndCloseFrames() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final WebSocketSession session = new WebSocketSession( + WebSocketConfig.DEFAULT, + new ByteArrayInputStream(new byte[0]), + out, + null, + null, + Collections.emptyList()); + + session.sendText("hello"); + final int afterText = out.size(); + assertTrue(afterText > 0); + + session.close(1000, "done"); + final int afterClose = out.size(); + assertTrue(afterClose > afterText); + + session.close(1000, "done"); + assertEquals(afterClose, out.size(), "close should be sent once"); + } + + @Test + void decodeTextValidatesUtf8() throws Exception { + final ByteBuffer payload = StandardCharsets.UTF_8.encode("ok"); + assertEquals("ok", WebSocketSession.decodeText(payload)); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/example/WebSocketEchoServer.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/example/WebSocketEchoServer.java new file mode 100644 index 000000000..8753a3ef2 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/example/WebSocketEchoServer.java @@ -0,0 +1,117 @@ +/* + * ==================================================================== + * 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.core5.websocket.example; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.CountDownLatch; + +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketServer; +import org.apache.hc.core5.websocket.server.WebSocketServerBootstrap; + +/** + * Simple WebSocket echo server built on httpcore5-websocket. + *

+ * Usage: + * java -cp ... org.apache.hc.core5.websocket.example.WebSocketEchoServer [port] + */ +public final class WebSocketEchoServer { + + private WebSocketEchoServer() { + } + + public static void main(final String[] args) throws Exception { + final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080; + final CountDownLatch shutdown = new CountDownLatch(1); + + final WebSocketServer server = WebSocketServerBootstrap.bootstrap() + .setListenerPort(port) + .setCanonicalHostName("localhost") + .register("/echo", () -> new WebSocketHandler() { + @Override + public void onOpen(final WebSocketSession session) { + System.out.println("WebSocket open: " + session.getRemoteAddress()); + } + + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendPong(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onClose(final WebSocketSession session, final int statusCode, final String reason) { + System.out.println("WebSocket close: " + statusCode + " " + reason); + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + System.err.println("WebSocket error: " + cause.getMessage()); + cause.printStackTrace(System.err); + } + + @Override + public String selectSubprotocol(final java.util.List protocols) { + return protocols.isEmpty() ? null : protocols.get(0); + } + }) + .create(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + server.initiateShutdown(); + server.stop(); + shutdown.countDown(); + })); + + server.start(); + System.out.println("WebSocket echo server listening on ws://localhost:" + port + "/echo"); + shutdown.await(); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolExceptionTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolExceptionTest.java new file mode 100644 index 000000000..53222e52c --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolExceptionTest.java @@ -0,0 +1,41 @@ +/* + * ==================================================================== + * 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.core5.websocket.exceptions; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class WebSocketProtocolExceptionTest { + + @Test + void exposesCloseCode() { + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "protocol"); + assertEquals(1002, ex.closeCode); + assertEquals("protocol", ex.getMessage()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/ExtensionChainTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/ExtensionChainTest.java new file mode 100644 index 000000000..a79abeb83 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/ExtensionChainTest.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * 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.core5.websocket.extension; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +final class ExtensionChainTest { + + @Test + void addAndUsePmce_decodeRoundTrip() throws Exception { + final ExtensionChain chain = new ExtensionChain(); + final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null); + chain.add(pmce); + + final byte[] data = "compress me please".getBytes(StandardCharsets.UTF_8); + + final WebSocketExtensionChain.Encoded enc = pmce.newEncoder().encode(data, true, true); + final byte[] back = chain.newDecodeChain().decode(enc.payload); + + assertArrayEquals(data, back); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/MessageDeflateTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/MessageDeflateTest.java new file mode 100644 index 000000000..84a3b92f6 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/MessageDeflateTest.java @@ -0,0 +1,90 @@ +/* + * ==================================================================== + * 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.core5.websocket.extension; + +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.websocket.frame.FrameHeaderBits; +import org.junit.jupiter.api.Test; + +final class MessageDeflateTest { + + @Test + void rsvMask_isRSV1() { + final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null); + assertEquals(FrameHeaderBits.RSV1, pmce.rsvMask()); + } + + @Test + void encode_setsRSVOnlyOnFirst() { + final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null); + final WebSocketExtensionChain.Encoder enc = pmce.newEncoder(); + + final byte[] data = "hello".getBytes(StandardCharsets.UTF_8); + + final WebSocketExtensionChain.Encoded first = enc.encode(data, true, false); + final WebSocketExtensionChain.Encoded cont = enc.encode(data, false, true); + + assertTrue(first.setRsvOnFirst, "RSV on first fragment"); + assertFalse(cont.setRsvOnFirst, "no RSV on continuation"); + assertNotEquals(0, first.payload.length); + assertNotEquals(0, cont.payload.length); + } + + @Test + void roundTrip_message() throws Exception { + final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null); + final WebSocketExtensionChain.Encoder enc = pmce.newEncoder(); + final WebSocketExtensionChain.Decoder dec = pmce.newDecoder(); + + final String s = "The quick brown fox jumps over the lazy dog. " + + "The quick brown fox jumps over the lazy dog."; + final byte[] plain = s.getBytes(StandardCharsets.UTF_8); + + // Single-frame message: first=true, fin=true + final byte[] wire = enc.encode(plain, true, true).payload; + + assertTrue(wire.length > 0); + assertFalse(endsWithTail(wire), "tail must be stripped on wire"); + + final byte[] roundTrip = dec.decode(wire); + assertArrayEquals(plain, roundTrip); + } + + private static boolean endsWithTail(final byte[] b) { + if (b.length < 4) { + return false; + } + return b[b.length - 4] == 0x00 && b[b.length - 3] == 0x00 && (b[b.length - 2] & 0xFF) == 0xFF && (b[b.length - 1] & 0xFF) == 0xFF; + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameHeaderBitsTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameHeaderBitsTest.java new file mode 100644 index 000000000..9159a5a5b --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameHeaderBitsTest.java @@ -0,0 +1,43 @@ +/* + * ==================================================================== + * 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.core5.websocket.frame; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class FrameHeaderBitsTest { + + @Test + void exposesHeaderBitConstants() { + assertEquals(0x80, FrameHeaderBits.FIN); + assertEquals(0x40, FrameHeaderBits.RSV1); + assertEquals(0x20, FrameHeaderBits.RSV2); + assertEquals(0x10, FrameHeaderBits.RSV3); + assertEquals(0x80, FrameHeaderBits.MASK_BIT); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameOpcodeTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameOpcodeTest.java new file mode 100644 index 000000000..ccae85159 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameOpcodeTest.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * 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.core5.websocket.frame; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class FrameOpcodeTest { + + @Test + void identifiesOpCodes() { + assertTrue(FrameOpcode.isControl(FrameOpcode.PING)); + assertTrue(FrameOpcode.isData(FrameOpcode.TEXT)); + assertTrue(FrameOpcode.isContinuation(FrameOpcode.CONT)); + assertFalse(FrameOpcode.isData(FrameOpcode.CONT)); + } + + @Test + void namesOpcodes() { + assertEquals("TEXT", FrameOpcode.name(FrameOpcode.TEXT)); + assertEquals("0x3", FrameOpcode.name(0x3)); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameWriterTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameWriterTest.java new file mode 100644 index 000000000..f32785392 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameWriterTest.java @@ -0,0 +1,188 @@ +/* + * ==================================================================== + * 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.core5.websocket.frame; + +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV1; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + + +class FrameWriterTest { + + private static class Parsed { + int b0; + int b1; + int opcode; + boolean fin; + boolean mask; + long len; + final byte[] maskKey = new byte[4]; + int headerLen; + ByteBuffer payloadSlice; + } + + private static Parsed parse(final ByteBuffer frame) { + final ByteBuffer frameCopy = frame.asReadOnlyBuffer(); + final Parsed r = new Parsed(); + r.b0 = frameCopy.get() & 0xFF; + r.fin = (r.b0 & 0x80) != 0; + r.opcode = r.b0 & 0x0F; + + r.b1 = frameCopy.get() & 0xFF; + r.mask = (r.b1 & 0x80) != 0; + final int low = r.b1 & 0x7F; + if (low <= 125) { + r.len = low; + } else if (low == 126) { + r.len = frameCopy.getShort() & 0xFFFF; + } else { + r.len = frameCopy.getLong(); + } + + if (r.mask) { + frameCopy.get(r.maskKey); + } + r.headerLen = frameCopy.position(); + r.payloadSlice = frameCopy.slice(); + return r; + } + + private static byte[] unmask(final Parsed p) { + final byte[] out = new byte[(int) p.len]; + for (int i = 0; i < out.length; i++) { + int b = p.payloadSlice.get(i) & 0xFF; + b ^= p.maskKey[i & 3] & 0xFF; + out[i] = (byte) b; + } + return out; + } + + @Test + void text_small_masked_roundtrip() { + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.text("hello", true); + final Parsed p = parse(f); + assertTrue(p.fin); + assertEquals(FrameOpcode.TEXT, p.opcode); + assertTrue(p.mask, "client frame must be masked"); + assertEquals(5, p.len); + assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), unmask(p)); + } + + @Test + void binary_len_126_masked_roundtrip() { + final byte[] payload = new byte[300]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i & 0xFF); + } + + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true); + + final Parsed p = parse(f); + assertTrue(p.mask); + assertEquals(FrameOpcode.BINARY, p.opcode); + assertEquals(300, p.len); + assertArrayEquals(payload, unmask(p)); + } + + @Test + void binary_len_127_masked_roundtrip() { + final int len = 70000; + final byte[] payload = new byte[len]; + Arrays.fill(payload, (byte) 0xA5); + + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true); + + final Parsed p = parse(f); + assertTrue(p.mask); + assertEquals(FrameOpcode.BINARY, p.opcode); + assertEquals(len, p.len); + assertArrayEquals(payload, unmask(p)); + } + + @Test + void rsv1_set_with_frameWithRSV() { + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer payload = StandardCharsets.UTF_8.encode("x"); + // Use RSV1 bit + final ByteBuffer f = w.frameWithRSV(FrameOpcode.TEXT, payload, true, true, RSV1); + final Parsed p = parse(f); + assertTrue(p.fin); + assertEquals(FrameOpcode.TEXT, p.opcode); + assertTrue((p.b0 & RSV1) != 0, "RSV1 must be set"); + assertArrayEquals("x".getBytes(StandardCharsets.UTF_8), unmask(p)); + } + + @Test + void close_frame_contains_code_and_reason() { + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.close(1000, "done"); + final Parsed p = parse(f); + assertTrue(p.mask); + assertEquals(FrameOpcode.CLOSE, p.opcode); + assertTrue(p.len >= 2); + + final byte[] raw = unmask(p); + final int code = (raw[0] & 0xFF) << 8 | raw[1] & 0xFF; + final String reason = new String(raw, 2, raw.length - 2, StandardCharsets.UTF_8); + + assertEquals(1000, code); + assertEquals("done", reason); + } + + @Test + void closeEcho_masks_and_preserves_payload() { + // Build a close payload manually + final byte[] reason = "bye".getBytes(StandardCharsets.UTF_8); + final ByteBuffer payload = ByteBuffer.allocate(2 + reason.length); + payload.put((byte) (1000 >>> 8)); + payload.put((byte) (1000 & 0xFF)); + payload.put(reason); + payload.flip(); + + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.closeEcho(payload); + final Parsed p = parse(f); + + assertTrue(p.mask); + assertEquals(FrameOpcode.CLOSE, p.opcode); + assertEquals(2 + reason.length, p.len); + + final byte[] got = unmask(p); + assertEquals(1000, (got[0] & 0xFF) << 8 | got[1] & 0xFF); + assertEquals("bye", new String(got, 2, got.length - 2, StandardCharsets.UTF_8)); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/message/CloseCodecTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/message/CloseCodecTest.java new file mode 100644 index 000000000..56f168f92 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/message/CloseCodecTest.java @@ -0,0 +1,87 @@ +/* + * ==================================================================== + * 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.core5.websocket.message; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +final class CloseCodecTest { + + @Test + void readEmptyIs1005() { + final ByteBuffer empty = ByteBuffer.allocate(0); + assertEquals(1005, CloseCodec.readCloseCode(empty.asReadOnlyBuffer())); + assertEquals("", CloseCodec.readCloseReason(empty.asReadOnlyBuffer())); + } + + @Test + void readCodeAndReason() { + final ByteBuffer payload = ByteBuffer.allocate(2 + 4); + payload.put((byte) 0x03).put((byte) 0xE8); // 1000 + payload.put(StandardCharsets.UTF_8.encode("done")); + payload.flip(); + + // Use the SAME buffer so the position advances + final ByteBuffer buf = payload.asReadOnlyBuffer(); + assertEquals(1000, CloseCodec.readCloseCode(buf)); // advances position by 2 + assertEquals("done", CloseCodec.readCloseReason(buf)); // reads remaining bytes only + } + + @Test + void validateCloseCodes() { + assertTrue(CloseCodec.isValidToSend(1000)); + assertTrue(CloseCodec.isValidToReceive(1000)); + assertTrue(CloseCodec.isValidToSend(3000)); + assertTrue(CloseCodec.isValidToReceive(3000)); + + assertFalse(CloseCodec.isValidToSend(1005)); + assertFalse(CloseCodec.isValidToReceive(1005)); + assertFalse(CloseCodec.isValidToSend(1006)); + assertFalse(CloseCodec.isValidToReceive(1006)); + assertFalse(CloseCodec.isValidToSend(1015)); + assertFalse(CloseCodec.isValidToReceive(1015)); + + assertFalse(CloseCodec.isValidToSend(2000)); + assertFalse(CloseCodec.isValidToReceive(2000)); + } + + @Test + void truncateReasonUtf8_capsAt123Bytes() { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 130; i++) { + sb.append('a'); + } + final String truncated = CloseCodec.truncateReasonUtf8(sb.toString()); + assertEquals(123, truncated.getBytes(StandardCharsets.UTF_8).length); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketContextKeysTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketContextKeysTest.java new file mode 100644 index 000000000..6c5d057aa --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketContextKeysTest.java @@ -0,0 +1,39 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class WebSocketContextKeysTest { + + @Test + void exposesConnectionKey() { + assertEquals("httpcore.websocket.connection", WebSocketContextKeys.CONNECTION); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandlerTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandlerTest.java new file mode 100644 index 000000000..87ab0d2aa --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandlerTest.java @@ -0,0 +1,118 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.nio.AsyncPushProducer; +import org.apache.hc.core5.http.nio.ResponseChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http2.H2PseudoRequestHeaders; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.junit.jupiter.api.Test; + +class WebSocketH2ServerExchangeHandlerTest { + + private static final class CapturingResponseChannel implements ResponseChannel { + private HttpResponse response; + + @Override + public void sendInformation(final HttpResponse response, final HttpContext context) { + // not used + } + + @Override + public void sendResponse(final HttpResponse response, final EntityDetails entityDetails, final HttpContext context) { + this.response = response; + } + + @Override + public void pushPromise(final HttpRequest promise, final AsyncPushProducer responseProducer, final HttpContext context) { + // not used + } + + HttpResponse getResponse() { + return response; + } + } + + @Test + void rejectsNonConnectMethod() throws Exception { + final WebSocketH2ServerExchangeHandler handler = new WebSocketH2ServerExchangeHandler( + new WebSocketHandler() { + }, null, WebSocketExtensionRegistry.createDefault()); + + final HttpRequest request = new BasicHttpRequest(Method.GET, "/"); + request.addHeader(H2PseudoRequestHeaders.PROTOCOL, "websocket"); + + final CapturingResponseChannel channel = new CapturingResponseChannel(); + handler.handleRequest(request, null, channel, HttpCoreContext.create()); + + assertNotNull(channel.getResponse()); + assertEquals(HttpStatus.SC_BAD_REQUEST, channel.getResponse().getCode()); + } + + @Test + void rejectsMissingProtocolHeader() throws Exception { + final WebSocketH2ServerExchangeHandler handler = new WebSocketH2ServerExchangeHandler( + new WebSocketHandler() { + }, null, WebSocketExtensionRegistry.createDefault()); + + final HttpRequest request = new BasicHttpRequest(Method.CONNECT, "/echo"); + + final CapturingResponseChannel channel = new CapturingResponseChannel(); + handler.handleRequest(request, null, channel, HttpCoreContext.create()); + + assertNotNull(channel.getResponse()); + assertEquals(HttpStatus.SC_BAD_REQUEST, channel.getResponse().getCode()); + } + + @Test + void rejectsUnknownProtocol() throws Exception { + final WebSocketH2ServerExchangeHandler handler = new WebSocketH2ServerExchangeHandler( + new WebSocketHandler() { + }, null, WebSocketExtensionRegistry.createDefault()); + + final HttpRequest request = new BasicHttpRequest(Method.CONNECT, "/echo"); + request.addHeader(H2PseudoRequestHeaders.PROTOCOL, "chat"); + + final CapturingResponseChannel channel = new CapturingResponseChannel(); + handler.handleRequest(request, null, channel, HttpCoreContext.create()); + + assertNotNull(channel.getResponse()); + assertEquals(HttpStatus.SC_BAD_REQUEST, channel.getResponse().getCode()); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketHttpServiceTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketHttpServiceTest.java new file mode 100644 index 000000000..6394f94ef --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketHttpServiceTest.java @@ -0,0 +1,148 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.net.SocketAddress; + +import javax.net.ssl.SSLSession; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.EndpointDetails; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.ProtocolVersion; +import org.apache.hc.core5.http.io.HttpServerConnection; +import org.apache.hc.core5.http.io.HttpServerRequestHandler; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http.impl.HttpProcessors; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Test; + +class WebSocketHttpServiceTest { + + @Test + void setsConnectionInContext() throws Exception { + final HttpProcessor processor = HttpProcessors.server(); + final HttpServerRequestHandler handler = (request, trigger, context) -> { + }; + final WebSocketHttpService service = new WebSocketHttpService(processor, handler, null, null, null); + final HttpContext context = HttpCoreContext.create(); + + final FailingConnection connection = new FailingConnection(); + final IOException thrown = assertThrows(IOException.class, + () -> service.handleRequest(connection, context)); + assertEquals("boom", thrown.getMessage()); + assertEquals(connection, context.getAttribute(WebSocketContextKeys.CONNECTION)); + } + + private static final class FailingConnection implements HttpServerConnection { + @Override + public ClassicHttpRequest receiveRequestHeader() throws HttpException, IOException { + throw new IOException("boom"); + } + + @Override + public void receiveRequestEntity(final ClassicHttpRequest request) throws HttpException, IOException { + } + + @Override + public void sendResponseHeader(final ClassicHttpResponse response) throws HttpException, IOException { + } + + @Override + public void sendResponseEntity(final ClassicHttpResponse response) throws HttpException, IOException { + } + + @Override + public boolean isDataAvailable(final Timeout timeout) { + return false; + } + + @Override + public boolean isStale() { + return false; + } + + @Override + public void flush() { + } + + @Override + public void close() throws IOException { + } + + @Override + public void close(final CloseMode closeMode) { + } + + @Override + public Timeout getSocketTimeout() { + return Timeout.ZERO_MILLISECONDS; + } + + @Override + public void setSocketTimeout(final Timeout timeout) { + } + + @Override + public EndpointDetails getEndpointDetails() { + return null; + } + + @Override + public SocketAddress getLocalAddress() { + return null; + } + + @Override + public SocketAddress getRemoteAddress() { + return null; + } + + @Override + public ProtocolVersion getProtocolVersion() { + return null; + } + + @Override + public SSLSession getSSLSession() { + return null; + } + + @Override + public boolean isOpen() { + return true; + } + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrapTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrapTest.java new file mode 100644 index 000000000..18acfe471 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrapTest.java @@ -0,0 +1,44 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.junit.jupiter.api.Test; + +class WebSocketServerBootstrapTest { + + @Test + void createsServerWithDefaults() { + final WebSocketServer server = WebSocketServerBootstrap.bootstrap() + .register("/ws", () -> new WebSocketHandler() { + }) + .create(); + assertNotNull(server); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactoryTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactoryTest.java new file mode 100644 index 000000000..e9111188c --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactoryTest.java @@ -0,0 +1,52 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.net.ServerSocket; +import java.net.Socket; + +import org.junit.jupiter.api.Test; + +class WebSocketServerConnectionFactoryTest { + + @Test + void createsBoundConnection() throws Exception { + final ServerSocket server = new ServerSocket(0); + final Socket client = new Socket("127.0.0.1", server.getLocalPort()); + final Socket socket = server.accept(); + client.close(); + server.close(); + + final WebSocketServerConnectionFactory factory = new WebSocketServerConnectionFactory("http", null, null); + final WebSocketServerConnection conn = factory.createConnection(socket); + assertNotNull(conn.getSocketInputStream()); + assertNotNull(conn.getSocketOutputStream()); + conn.close(); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessorTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessorTest.java new file mode 100644 index 000000000..d09a3d415 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessorTest.java @@ -0,0 +1,145 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.junit.jupiter.api.Test; + +class WebSocketServerProcessorTest { + + private static final byte[] MASK = new byte[]{1, 2, 3, 4}; + + private static byte[] maskedFrame(final int opcode, final byte[] payload) { + final int len = payload.length; + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x80 | (opcode & 0x0F)); + if (len <= 125) { + out.write(0x80 | len); + } else if (len <= 0xFFFF) { + out.write(0x80 | 126); + out.write((len >> 8) & 0xFF); + out.write(len & 0xFF); + } else { + out.write(0x80 | 127); + final long l = len; + for (int i = 7; i >= 0; i--) { + out.write((int) ((l >> (i * 8)) & 0xFF)); + } + } + out.write(MASK, 0, MASK.length); + for (int i = 0; i < len; i++) { + out.write(payload[i] ^ MASK[i % 4]); + } + return out.toByteArray(); + } + + private static byte[] closePayload(final int code, final String reason) { + final byte[] reasonBytes = reason != null ? reason.getBytes(StandardCharsets.UTF_8) : new byte[0]; + final byte[] payload = new byte[2 + reasonBytes.length]; + payload[0] = (byte) ((code >> 8) & 0xFF); + payload[1] = (byte) (code & 0xFF); + System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length); + return payload; + } + + @Test + void processesTextAndCloseFrames() throws Exception { + final byte[] text = "hello".getBytes(StandardCharsets.UTF_8); + final byte[] close = closePayload(1000, "bye"); + final ByteArrayOutputStream frames = new ByteArrayOutputStream(); + frames.write(maskedFrame(0x1, text)); + frames.write(maskedFrame(0x8, close)); + + final ByteArrayInputStream in = new ByteArrayInputStream(frames.toByteArray()); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final WebSocketSession session = new WebSocketSession( + WebSocketConfig.DEFAULT, + in, + out, + null, + null, + Collections.emptyList()); + + final TrackingHandler handler = new TrackingHandler(); + final WebSocketServerProcessor processor = new WebSocketServerProcessor(session, handler, 1024); + processor.process(); + + assertEquals("hello", handler.text); + assertEquals(1000, handler.closeCode); + assertEquals("bye", handler.closeReason); + assertTrue(out.size() > 0, "server should send close response"); + } + + private static final class TrackingHandler implements WebSocketHandler { + private String text; + private int closeCode; + private String closeReason; + + @Override + public void onText(final WebSocketSession session, final String payload) { + this.text = payload; + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer payload) { + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer payload) { + } + + @Override + public void onPong(final WebSocketSession session, final ByteBuffer payload) { + } + + @Override + public void onClose(final WebSocketSession session, final int code, final String reason) { + this.closeCode = code; + this.closeReason = reason; + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + } + + @Override + public String selectSubprotocol(final java.util.List protocols) { + return null; + } + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandlerTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandlerTest.java new file mode 100644 index 000000000..535ab010f --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandlerTest.java @@ -0,0 +1,158 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequestMapper; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.HttpServerRequestHandler.ResponseTrigger; +import org.apache.hc.core5.http.message.BasicClassicHttpRequest; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketConstants; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.junit.jupiter.api.Test; + +class WebSocketServerRequestHandlerTest { + + @Test + void upgradesValidRequest() throws Exception { + final AtomicBoolean opened = new AtomicBoolean(false); + final Supplier supplier = () -> new WebSocketHandler() { + @Override + public void onOpen(final WebSocketSession session) { + opened.set(true); + } + + @Override + public void onText(final WebSocketSession session, final String payload) { + } + + @Override + public void onBinary(final WebSocketSession session, final java.nio.ByteBuffer payload) { + } + + @Override + public void onPing(final WebSocketSession session, final java.nio.ByteBuffer payload) { + } + + @Override + public void onPong(final WebSocketSession session, final java.nio.ByteBuffer payload) { + } + + @Override + public void onClose(final WebSocketSession session, final int code, final String reason) { + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + } + + @Override + public String selectSubprotocol(final java.util.List protocols) { + return null; + } + }; + + final HttpRequestMapper> mapper = (request, context) -> supplier; + final WebSocketServerRequestHandler handler = new WebSocketServerRequestHandler( + mapper, + WebSocketConfig.DEFAULT, + WebSocketExtensionRegistry.createDefault()); + + final BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "/ws"); + request.addHeader(HttpHeaders.CONNECTION, "Upgrade"); + request.addHeader(HttpHeaders.UPGRADE, "websocket"); + request.addHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION, "13"); + request.addHeader(WebSocketConstants.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ=="); + + final RecordingTrigger trigger = new RecordingTrigger(); + final HttpContext context = HttpCoreContext.create(); + + final WebSocketServerConnection connection = createConnection(); + context.setAttribute(WebSocketContextKeys.CONNECTION, connection); + handler.handle(request, trigger, context); + + assertNotNull(trigger.response); + assertEquals(HttpStatus.SC_SWITCHING_PROTOCOLS, trigger.response.getCode()); + assertEquals("websocket", trigger.response.getFirstHeader(HttpHeaders.UPGRADE).getValue()); + assertTrue(opened.get()); + connection.close(); + } + + @Test + void returnsUpgradeRequiredForNonUpgradeRequest() throws Exception { + final WebSocketServerRequestHandler handler = new WebSocketServerRequestHandler( + (request, context) -> () -> null, + WebSocketConfig.DEFAULT, + new WebSocketExtensionRegistry()); + + final BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "/ws"); + final RecordingTrigger trigger = new RecordingTrigger(); + handler.handle(request, trigger, HttpCoreContext.create()); + + assertEquals(HttpStatus.SC_UPGRADE_REQUIRED, trigger.response.getCode()); + } + + private static WebSocketServerConnection createConnection() throws IOException { + final ServerSocket server = new ServerSocket(0); + final Socket client = new Socket("127.0.0.1", server.getLocalPort()); + final Socket socket = server.accept(); + client.close(); + server.close(); + final WebSocketServerConnectionFactory factory = new WebSocketServerConnectionFactory("http", null, null); + return factory.createConnection(socket); + } + + private static final class RecordingTrigger implements ResponseTrigger { + ClassicHttpResponse response; + + @Override + public void sendInformation(final ClassicHttpResponse response) { + this.response = response; + } + + @Override + public void submitResponse(final ClassicHttpResponse response) { + this.response = response; + } + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerTest.java new file mode 100644 index 000000000..129b5b38a --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerTest.java @@ -0,0 +1,44 @@ +/* + * ==================================================================== + * 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.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +class WebSocketServerTest { + + @Test + void exposesServerInfo() { + final WebSocketServer server = WebSocketServerBootstrap.bootstrap() + .register("/ws", () -> new org.apache.hc.core5.websocket.WebSocketHandler() { + }) + .create(); + assertNotNull(server); + server.getLocalPort(); + } +} diff --git a/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/util/ByteBufferPoolTest.java b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/util/ByteBufferPoolTest.java new file mode 100644 index 000000000..35918dbc5 --- /dev/null +++ b/httpcore5-websocket/src/test/java/org/apache/hc/core5/websocket/util/ByteBufferPoolTest.java @@ -0,0 +1,61 @@ +/* + * ==================================================================== + * 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.core5.websocket.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.Test; + +class ByteBufferPoolTest { + + @Test + void acquireAndReleaseBuffers() { + final ByteBufferPool pool = new ByteBufferPool(16, 2); + final ByteBuffer a = pool.acquire(); + final ByteBuffer b = pool.acquire(); + assertNotNull(a); + assertNotNull(b); + pool.release(a); + pool.release(b); + assertEquals(2, pool.pooledCount()); + final ByteBuffer c = pool.acquire(); + assertEquals(1, pool.pooledCount()); + pool.release(c); + pool.clear(); + assertEquals(0, pool.pooledCount()); + } + + @Test + void rejectsInvalidConfig() { + assertThrows(IllegalArgumentException.class, () -> new ByteBufferPool(0, 1)); + assertThrows(IllegalArgumentException.class, () -> new ByteBufferPool(1, -1)); + } +} diff --git a/pom.xml b/pom.xml index 6ad1bfbda..76fbbc674 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,7 @@ httpcore5-h2 httpcore5-reactive httpcore5-jackson2 + httpcore5-websocket httpcore5-testing @@ -109,6 +110,11 @@ httpcore5-jackson2 ${project.version} + + org.apache.httpcomponents.core5 + httpcore5-websocket + ${project.version} + org.apache.httpcomponents.core5 httpcore5-testing