diff --git a/src/main/java/io/vertx/openapi/contract/impl/ServerImpl.java b/src/main/java/io/vertx/openapi/contract/impl/ServerImpl.java index 244ad11..eae79ae 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/ServerImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/ServerImpl.java @@ -18,7 +18,8 @@ import io.vertx.core.json.JsonObject; import io.vertx.openapi.contract.Server; import java.net.MalformedURLException; -import java.net.URL; +import java.net.URI; +import java.net.URISyntaxException; public class ServerImpl implements Server { @@ -37,8 +38,11 @@ public ServerImpl(JsonObject serverModel) { throw createUnsupportedFeature("Server Variables"); } try { - this.basePath = new URL(url.endsWith("/") ? url.substring(0, url.length() - 1) : url).getPath(); - } catch (MalformedURLException e) { + URI uri = new URI(url.endsWith("/") ? url.substring(0, url.length() - 1) : url); + // The URL may be relative, to indicate that the host location is relative to the location where + // the document containing the Server Object is being served. + this.basePath = uri.isAbsolute() ? uri.toURL().getPath() : uri.getPath(); + } catch (URISyntaxException | MalformedURLException | IllegalArgumentException e) { throw createInvalidContract("The specified URL is malformed: " + url, e); } } diff --git a/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java index b3f2731..22a72db 100644 --- a/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java +++ b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java @@ -64,7 +64,7 @@ public void checkSyntacticalCorrectness() { throw new ValidatorException(msg, MISSING_REQUIRED_PARAMETER); } - parts = MultipartPart.fromMultipartBody(content.toString(), boundary); + parts = MultipartPart.fromMultipartBody(content, boundary); } @Override diff --git a/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java index 73322ee..75a5903 100644 --- a/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java +++ b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java @@ -17,6 +17,7 @@ import io.vertx.core.buffer.Buffer; import io.vertx.openapi.validation.ValidatorException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -29,49 +30,103 @@ public class MultipartPart { CASE_INSENSITIVE); private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile("Content-Type: (.*)", CASE_INSENSITIVE); + private static final byte[] HEADER_SECTION_DELIMITER = { '\r', '\n', '\r', '\n' }; + private final String name; private final String contentType; private final Buffer body; - // Should only be called by MultipartPartFormTransformer - static List fromMultipartBody(String body, String boundary) { + // Should only be called by MultipartFormAnalyser + static List fromMultipartBody(Buffer body, String boundary) { return parseParts(body, boundary).stream().map(MultipartPart::parsePart).collect(Collectors.toList()); } // VisibleForTesting - public static List parseParts(String body, String boundary) { - String delimiter = "--" + boundary + "|\r\n--" + boundary; - String[] rawParts = body.split(delimiter); + public static List parseParts(Buffer body, String boundary) { + // The parts must be split on byte level, because bodies of binary parts could contain byte sequences + // that are invalid in UTF-8 and would get corrupted by a String round trip. + byte[] bytes = body.getBytes(); + byte[] delimiter = ("--" + boundary).getBytes(StandardCharsets.US_ASCII); + byte[] delimiterWithLineBreak = ("\r\n--" + boundary).getBytes(StandardCharsets.US_ASCII); + + List rawParts = new ArrayList<>(); + int segmentStart = 0; + int i = 0; + while (i < bytes.length) { + // The line break preceding the delimiter belongs to the delimiter, not to the part body. + byte[] match = matchesAt(bytes, i, delimiter) ? delimiter + : matchesAt(bytes, i, delimiterWithLineBreak) ? delimiterWithLineBreak : null; + if (match == null) { + i++; + } else { + rawParts.add(body.getBuffer(segmentStart, i)); + i += match.length; + segmentStart = i; + } + } + rawParts.add(body.getBuffer(segmentStart, bytes.length)); - if (rawParts.length < 3 || !"--".equals(rawParts[rawParts.length - 1].strip())) { + if (rawParts.size() < 3 || !"--".equals(rawParts.get(rawParts.size() - 1).toString().strip())) { String msg = "The multipart message doesn't contain any parts, or has an invalid structure."; throw new ValidatorException(msg, INVALID_VALUE); } - List parts = new ArrayList<>(rawParts.length - 2); + List parts = new ArrayList<>(rawParts.size() - 2); // Omit first and last part, because first part is everything up to the first delimiter and the last part // contains "--"; - for (int i = 1; i < rawParts.length - 1; i++) { - parts.add(rawParts[i].strip()); + for (int j = 1; j < rawParts.size() - 1; j++) { + parts.add(stripLeadingWhitespace(rawParts.get(j))); } return parts; } + private static boolean matchesAt(byte[] bytes, int offset, byte[] pattern) { + if (offset + pattern.length > bytes.length) { + return false; + } + for (int i = 0; i < pattern.length; i++) { + if (bytes[offset + i] != pattern[i]) { + return false; + } + } + return true; + } + + private static Buffer stripLeadingWhitespace(Buffer rawPart) { + int start = 0; + while (start < rawPart.length()) { + byte b = rawPart.getByte(start); + if (b == ' ' || b == '\t' || b == '\r' || b == '\n') { + start++; + } else { + break; + } + } + return rawPart.getBuffer(start, rawPart.length()); + } + private static Optional parsePattern(Pattern pattern, String rawPart) { return pattern.matcher(rawPart).results().findFirst().map(m -> m.group(1)); } // VisibleForTesting - public static MultipartPart parsePart(String rawPart) { - String sectionDelimiterPattern = "\r\n\r\n"; - int sectionDelimiter = rawPart.indexOf(sectionDelimiterPattern); + public static MultipartPart parsePart(Buffer rawPart) { + int sectionDelimiter = -1; + byte[] bytes = rawPart.getBytes(); + for (int i = 0; i < bytes.length; i++) { + if (matchesAt(bytes, i, HEADER_SECTION_DELIMITER)) { + sectionDelimiter = i; + break; + } + } // if no empty line exists, there are only headers - String headerSection = sectionDelimiter == -1 ? rawPart : rawPart.substring(0, sectionDelimiter); - String body = - sectionDelimiter == -1 ? null : rawPart.substring(sectionDelimiter + sectionDelimiterPattern.length()); + String headerSection = sectionDelimiter == -1 ? rawPart.toString() + : rawPart.getBuffer(0, sectionDelimiter).toString(); + Buffer body = sectionDelimiter == -1 ? null + : rawPart.getBuffer(sectionDelimiter + HEADER_SECTION_DELIMITER.length, rawPart.length()); String name = parsePattern(NAME_PATTERN, headerSection).orElseThrow(() -> { String msg = "A part of the multipart message doesn't contain a name."; @@ -81,7 +136,7 @@ public static MultipartPart parsePart(String rawPart) { // If no header is set, content type defaults to text/plain String contentType = parsePattern(CONTENT_TYPE_PATTERN, headerSection).orElse("text/plain"); - return new MultipartPart(name, contentType, body == null ? null : Buffer.buffer(body)); + return new MultipartPart(name, contentType, body == null || body.length() == 0 ? null : body); } public MultipartPart(String name, String contentType, Buffer body) { diff --git a/src/test/java/io/vertx/tests/contract/impl/ServerImplTest.java b/src/test/java/io/vertx/tests/contract/impl/ServerImplTest.java index 0d035cf..7ed091b 100644 --- a/src/test/java/io/vertx/tests/contract/impl/ServerImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/ServerImplTest.java @@ -45,7 +45,11 @@ private static Stream testBasePathExtraction() { Arguments.of("https://example.com/", ""), Arguments.of("https://example.com/foo", "/foo"), Arguments.of("https://example.com/foo/", "/foo"), - Arguments.of("https://example.com/foo/bar", "/foo/bar")); + Arguments.of("https://example.com/foo/bar", "/foo/bar"), + Arguments.of("/", ""), + Arguments.of("/v1/api", "/v1/api"), + Arguments.of("/v1/api/", "/v1/api"), + Arguments.of("//example.com/foo", "/foo")); } @ParameterizedTest(name = "{index} BasePath extraction: {0} should result into {1}") @@ -56,6 +60,17 @@ void testBasePathExtraction(String url, String basePath) { assertThat(server.getBasePath()).isEqualTo(basePath); } + @Test + void testRelativeUrlGetters() { + String url = "/v1/api"; + JsonObject model = new JsonObject().put("url", url); + Server server = new ServerImpl(model); + + assertThat(server.getOpenAPIModel()).isEqualTo(model); + assertThat(server.getURL()).isEqualTo(url); + assertThat(server.getBasePath()).isEqualTo("/v1/api"); + } + @Test void testExceptions() { String msgUnsupported = "The passed OpenAPI contract contains a feature that is not supported: Server Variables"; @@ -72,5 +87,12 @@ void testExceptions() { () -> new ServerImpl(new JsonObject().put("url", "http://foo.bar:-80"))); assertThat(exceptionInvalid.type()).isEqualTo(ContractErrorType.INVALID_SPEC); assertThat(exceptionInvalid).hasMessageThat().isEqualTo(msgInvalid); + + String msgInvalidRelative = "The passed OpenAPI contract is invalid: The specified URL is malformed: /v1/ api"; + OpenAPIContractException exceptionInvalidRelative = + assertThrows(OpenAPIContractException.class, + () -> new ServerImpl(new JsonObject().put("url", "/v1/ api"))); + assertThat(exceptionInvalidRelative.type()).isEqualTo(ContractErrorType.INVALID_SPEC); + assertThat(exceptionInvalidRelative).hasMessageThat().isEqualTo(msgInvalidRelative); } } diff --git a/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java b/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java index a9360d8..79fcc75 100644 --- a/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java +++ b/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java @@ -110,6 +110,29 @@ void testTransformOctetStream() throws IOException { assertThat(result.getBuffer("address").toJsonObject()).isEqualTo(expected); } + @Test + void testTransformOctetStreamWithBinaryContent() { + // Contains bytes that are invalid in UTF-8 (e.g. 0x89, 0xFF) and whitespace bytes at the edges, + // which must survive the transformation unmodified. + byte[] binaryContent = { 0x0A, 0x20, (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, + (byte) 0xFF, (byte) 0xFE, 0x42, 0x09, 0x20, 0x0D, 0x0A }; + + Buffer multipartBody = Buffer.buffer() + .appendString("--abcde12345\r\n") + .appendString("Content-Disposition: form-data; name=\"file\"; filename=\"blob.bin\"\r\n") + .appendString("Content-Type: application/octet-stream\r\n") + .appendString("\r\n") + .appendBytes(binaryContent) + .appendString("\r\n--abcde12345--"); + String contentType = "multipart/form-data; boundary=abcde12345"; + + MultipartFormAnalyser analyser = new MultipartFormAnalyser(contentType, multipartBody, REQUEST); + analyser.checkSyntacticalCorrectness(); // must always be executed before transform + + JsonObject result = (JsonObject) analyser.transform(); + assertThat(result.getBuffer("file")).isEqualTo(Buffer.buffer(binaryContent)); + } + @Test void testTransformContinueWhenBodyEmpty() throws IOException { Buffer multipartBody = Buffer.buffer(Files.readString(TEST_RESOURCE_PATH.resolve("multipart_id_no_body.txt"))); diff --git a/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java b/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java index d6bca45..d2f8efd 100644 --- a/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java +++ b/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java @@ -32,19 +32,23 @@ class MultipartPartTest { private static final Path TEST_RESOURCE_PATH = getRelatedTestResourcePath(MultipartPartTest.class); + private static Buffer readResource(String file) throws IOException { + return Buffer.buffer(Files.readAllBytes(TEST_RESOURCE_PATH.resolve(file))); + } + @Test void testParseParts() throws IOException { - String part1 = Files.readString(TEST_RESOURCE_PATH.resolve("part1.txt")); - String part2 = Files.readString(TEST_RESOURCE_PATH.resolve("part2.txt")); + Buffer part1 = readResource("part1.txt"); + Buffer part2 = readResource("part2.txt"); - String multipartBody = Files.readString(TEST_RESOURCE_PATH.resolve("multipart.txt")); + Buffer multipartBody = readResource("multipart.txt"); Truth.assertThat(MultipartPart.parseParts(multipartBody, "abcde12345")).containsExactly(part1, part2); } @ParameterizedTest @ValueSource(strings = { "multipart_invalid_structure", "multipart_invalid_structure_2" }) void testParsePartsInvalidStructure(String file) throws IOException { - String multipartBody = Files.readString(TEST_RESOURCE_PATH.resolve(file + ".txt")); + Buffer multipartBody = readResource(file + ".txt"); ValidatorException exception = assertThrows(ValidatorException.class, () -> MultipartPart.parseParts(multipartBody, "abcde12345")); @@ -56,13 +60,13 @@ void testParsePartsInvalidStructure(String file) throws IOException { @Test void testParsePart() throws IOException { - String part1 = Files.readString(TEST_RESOURCE_PATH.resolve("part1.txt")); + Buffer part1 = readResource("part1.txt"); MultipartPart mpp1 = MultipartPart.parsePart(part1); assertThat(mpp1.getName()).isEqualTo("id"); assertThat(mpp1.getContentType()).isEqualTo("text/plain"); assertThat(mpp1.getBody()).isEqualTo(Buffer.buffer("123e4567-e89b-12d3-a456-426655440000")); - String part2 = Files.readString(TEST_RESOURCE_PATH.resolve("part2.txt")); + Buffer part2 = readResource("part2.txt"); MultipartPart mpp2 = MultipartPart.parsePart(part2); assertThat(mpp2.getName()).isEqualTo("address"); assertThat(mpp2.getContentType()).isEqualTo("application/json"); @@ -71,7 +75,7 @@ void testParsePart() throws IOException { .put("city", "Hillsbery, UT"); assertThat(mpp2.getBody().toJsonObject()).isEqualTo(body); - String part3 = Files.readString(TEST_RESOURCE_PATH.resolve("part3.txt")); + Buffer part3 = readResource("part3.txt"); MultipartPart mpp3 = MultipartPart.parsePart(part3); assertThat(mpp3.getName()).isEqualTo("randomBinary"); assertThat(mpp3.getContentType()).isEqualTo("application/octet-stream"); @@ -80,7 +84,7 @@ void testParsePart() throws IOException { @Test void testParsePartWithoutName() throws IOException { - String part = Files.readString(TEST_RESOURCE_PATH.resolve("part_without_name.txt")); + Buffer part = readResource("part_without_name.txt"); ValidatorException exception = assertThrows(ValidatorException.class, () -> MultipartPart.parsePart(part)); @@ -92,7 +96,7 @@ void testParsePartWithoutName() throws IOException { @Test void testParsePartWithoutContentType() throws IOException { - String part = Files.readString(TEST_RESOURCE_PATH.resolve("part_without_contenttype.txt")); + Buffer part = readResource("part_without_contenttype.txt"); MultipartPart mpp = MultipartPart.parsePart(part); assertThat(mpp.getName()).isEqualTo("id"); @@ -102,7 +106,7 @@ void testParsePartWithoutContentType() throws IOException { @Test void testParsePartWithoutBody() throws IOException { - String part = Files.readString(TEST_RESOURCE_PATH.resolve("part_without_body.txt")); + Buffer part = readResource("part_without_body.txt"); MultipartPart mpp = MultipartPart.parsePart(part); assertThat(mpp.getName()).isEqualTo("id"); assertThat(mpp.getContentType()).isEqualTo("text/plain");