diff --git a/docs/guide/client-api.md b/docs/guide/client-api.md index e1926e33..1460d4df 100644 --- a/docs/guide/client-api.md +++ b/docs/guide/client-api.md @@ -127,7 +127,7 @@ A `SignalMessage` is a session description (`OFFER`, `ANSWER`) or an ICE candida {"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.7 49203 typ srflx raddr 0.0.0.0 rport 0","sdpMid":"0","sdpMLineIndex":0} ``` -`SignalMessage.fromJson(String)` reads these, what a browser's `JSON.stringify(pc.localDescription)` and `JSON.stringify(candidate)` write, and the `{"description": ...}` and `{"candidate": {...}}` envelopes of the perfect negotiation examples. Anything else throws an `IllegalArgumentException`, as does text longer than a MiB or nested deeper than 64 levels, so a message from the remote side cannot exhaust the stack. Routing, such as room names or peer ids, belongs in an envelope of your own around this JSON. +`SignalMessage.fromJson(String)` reads these, what a browser's `JSON.stringify(pc.localDescription)` and `JSON.stringify(candidate)` write, and the `{"description": ...}` and `{"candidate": {...}}` envelopes of the perfect negotiation examples. Anything else throws an `IllegalArgumentException` that says what is wrong and where, as does text longer than a MiB or nested deeper than 64 levels, so a message from the remote side cannot exhaust the stack. The JSON must be strict (RFC 8259): no raw line breaks or other control characters inside strings, no member name twice in one object, no numbers like `01`, `1.` or `1e999`. An `sdpMLineIndex` must be an integer from 0 to 65535; `1.9` is rejected rather than truncated to 1. Routing, such as room names or peer ids, belongs in an envelope of your own around this JSON. A browser peer that only answers needs no more than this: diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java index 09b43b34..d6ebaf9d 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java @@ -26,6 +26,12 @@ * library. Objects become {@code Map}s, arrays {@code List}s, numbers {@code * Double}s. *

+ * The grammar is that of RFC 8259 and nothing more lenient: no control + * characters inside strings, numbers without leading zeros or bare points, + * only the four whitespace characters JSON has. A member name must not appear + * twice in an object, which would leave open which of the values counts, and + * a number must fit a {@code double}, since JSON has no infinities. + *

* Text comes from the remote side, so it is bounded: in length, like the * answers of WHIP and WHEP endpoints, and in nesting, which a signaling * message needs only a few levels of and the recursive parser would otherwise @@ -143,7 +149,7 @@ private Object readValue() { if (c == '-' || (c >= '0' && c <= '9')) { return readNumber(); } - throw error("Unexpected character '" + c + "'"); + throw error("Unexpected character " + describe(c)); } } @@ -164,7 +170,13 @@ private Map readObject() { throw error("Expected a member name"); } + int start = pos; String name = readString(); + + if (object.containsKey(name)) { + throw error("Duplicate member name " + quote(name), start); + } + skipWhitespace(); if (peek() != ':') { @@ -229,6 +241,9 @@ private String readString() { if (c == '"') { return out.toString(); } + if (c < 0x20) { + throw error("Unescaped control character " + describe(c) + " in a string", pos - 1); + } if (c != '\\') { out.append(c); continue; @@ -264,12 +279,20 @@ private String readString() { if (pos + 4 > text.length()) { throw error("Incomplete unicode escape"); } - try { - out.append((char) Integer.parseInt(text.substring(pos, pos + 4), 16)); - } - catch (NumberFormatException ex) { - throw error("Invalid unicode escape"); + + int code = 0; + + for (int i = 0; i < 4; i++) { + int digit = hexDigit(text.charAt(pos + i)); + + if (digit < 0) { + throw error("Invalid unicode escape", pos + i); + } + + code = code << 4 | digit; } + + out.append((char) code); pos += 4; break; default: @@ -281,16 +304,59 @@ private String readString() { private Double readNumber() { int start = pos; - while (pos < text.length() && "+-0123456789.eE".indexOf(text.charAt(pos)) >= 0) { + if (next('-')) { pos++; } + if (next('0')) { + pos++; - try { - return Double.valueOf(text.substring(start, pos)); + if (nextIsDigit()) { + throw error("Leading zero in a number"); + } } - catch (NumberFormatException e) { - throw error("Invalid number"); + else { + readDigits("Expected a digit"); } + if (next('.')) { + pos++; + readDigits("Expected a digit after the decimal point"); + } + if (next('e') || next('E')) { + pos++; + + if (next('+') || next('-')) { + pos++; + } + + readDigits("Expected a digit in the exponent"); + } + + // The grammar above is a subset of what Double.valueOf reads. + Double value = Double.valueOf(text.substring(start, pos)); + + if (value.isInfinite()) { + throw error("Number out of range", start); + } + + return value; + } + + private void readDigits(String message) { + if (!nextIsDigit()) { + throw error(message); + } + + while (nextIsDigit()) { + pos++; + } + } + + private boolean next(char c) { + return pos < text.length() && text.charAt(pos) == c; + } + + private boolean nextIsDigit() { + return pos < text.length() && text.charAt(pos) >= '0' && text.charAt(pos) <= '9'; } private void enter() { @@ -316,12 +382,55 @@ private char peek() { } private void skipWhitespace() { - while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) { + while (pos < text.length() && isWhitespace(text.charAt(pos))) { pos++; } } private IllegalArgumentException error(String message) { - return new IllegalArgumentException(message + " at position " + pos); + return error(message, pos); + } + + private static IllegalArgumentException error(String message, int position) { + return new IllegalArgumentException(message + " at position " + position); + } + + private static boolean isWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; + } + + private static int hexDigit(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + + return -1; + } + + /** + * @return the character for a message, readable whatever it is. + */ + private static String describe(char c) { + if (c >= 0x20 && c < 0x7f) { + return "'" + c + "'"; + } + + return String.format("U+%04X", (int) c); + } + + /** + * @return the text as a JSON string for a message, cut short if long. + */ + private static String quote(String text) { + StringBuilder out = new StringBuilder(); + writeString(out, text.length() > 32 ? text.substring(0, 32) + "..." : text); + + return out.toString(); } } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java index 42431cd7..fe3533b7 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java @@ -64,6 +64,12 @@ public enum Type { private static final SignalMessage END_OF_CANDIDATES = new SignalMessage(Type.END_OF_CANDIDATES, null, null); + /** + * The largest {@code sdpMLineIndex}, which the browser API types as an + * {@code unsigned short}. + */ + private static final int MAX_M_LINE_INDEX = 0xFFFF; + private final Type type; private final RTCSessionDescription description; @@ -104,6 +110,11 @@ public static SignalMessage of(RTCSessionDescription description) { * @param candidate a local ICE candidate. * * @return a message that carries the candidate. + * + * @throws IllegalArgumentException if the candidate's {@code + * sdpMLineIndex} is not from 0 to + * 65535, which {@link #fromJson} would + * not read back. */ public static SignalMessage of(RTCIceCandidate candidate) { requireNonNull(candidate, "candidate"); @@ -113,6 +124,8 @@ public static SignalMessage of(RTCIceCandidate candidate) { return END_OF_CANDIDATES; } + checkMLineIndex(candidate.sdpMLineIndex); + return new SignalMessage(Type.CANDIDATE, null, candidate); } @@ -130,10 +143,12 @@ public static SignalMessage endOfCandidates() { * * @return the message. * - * @throws IllegalArgumentException if the text is no JSON or no signaling - * message, is longer than a MiB, or - * nests arrays and objects deeper than - * 64 levels. + * @throws IllegalArgumentException if the text is no strict JSON or no + * signaling message, is longer than a + * MiB, nests arrays and objects deeper + * than 64 levels, or has an {@code + * sdpMLineIndex} that is not an integer + * from 0 to 65535. */ public static SignalMessage fromJson(String json) { requireNonNull(json, "json"); @@ -274,7 +289,7 @@ private static SignalMessage fromObject(Map object) { } return new SignalMessage(Type.CANDIDATE, null, new RTCIceCandidate( - (String) mid, index == null ? 0 : ((Number) index).intValue(), sdp)); + (String) mid, index == null ? 0 : mLineIndex((Number) index), sdp)); } Object type = object.get("type"); @@ -296,4 +311,32 @@ private static SignalMessage fromObject(Map object) { throw new IllegalArgumentException("Unsupported description type \"" + type + "\""); } } + + /** + * @param number the {@code sdpMLineIndex} of a JSON message. + * + * @return the index, if the number is an integer from 0 to 65535. + */ + private static int mLineIndex(Number number) { + double index = number.doubleValue(); + + // Checked as a double, not after a cast, which would truncate 1.9 to 1 + // and clamp 1e300 to Integer.MAX_VALUE. + if (index != Math.rint(index)) { + throw new IllegalArgumentException("\"sdpMLineIndex\" is not an integer: " + number); + } + if (index < 0 || index > MAX_M_LINE_INDEX) { + throw new IllegalArgumentException("\"sdpMLineIndex\" is not from 0 to " + MAX_M_LINE_INDEX + + ": " + (Math.abs(index) < 1e15 ? Long.toString((long) index) : number.toString())); + } + + return (int) index; + } + + private static void checkMLineIndex(int index) { + if (index < 0 || index > MAX_M_LINE_INDEX) { + throw new IllegalArgumentException("\"sdpMLineIndex\" is not from 0 to " + MAX_M_LINE_INDEX + + ": " + index); + } + } } diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/JsonTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/JsonTests.java new file mode 100644 index 00000000..837992a9 --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/JsonTests.java @@ -0,0 +1,393 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed 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. + */ + +package dev.onvoid.webrtc.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +class JsonTests { + + /** The number grammar of RFC 8259, section 6. */ + private static final Pattern NUMBER = Pattern.compile("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?"); + + @TestFactory + List invalidJsonRejected() { + // Input, then the start of the message it fails with. + return rejected( + "\"a\nb\"", "Unescaped control character U+000A in a string at position 2", + "\"\r\n\"", "Unescaped control character U+000D", + "\"\t\"", "Unescaped control character U+0009", + "\"\u0000\"", "Unescaped control character U+0000", + "\"\u001F\"", "Unescaped control character U+001F", + "{\"a\nb\":1}", "Unescaped control character U+000A", + "\"\\u+041\"", "Invalid unicode escape at position 3", + "\"\\u-041\"", "Invalid unicode escape", + "\"\\u00G0\"", "Invalid unicode escape at position 5", + "\"\\u\uFF10041\"", "Invalid unicode escape", + "\"\\u12\"", "Incomplete unicode escape", + "\"\\x\"", "Invalid escape '\\x'", + "\"abc", "Unterminated string", + "01", "Leading zero in a number at position 1", + "-01", "Leading zero in a number", + "00", "Leading zero in a number", + "1.", "Expected a digit after the decimal point at position 2", + "1.e5", "Expected a digit after the decimal point", + ".5", "Unexpected character '.'", + "+1", "Unexpected character '+'", + "-", "Expected a digit at position 1", + "--1", "Expected a digit", + "-a", "Expected a digit", + "1e", "Expected a digit in the exponent", + "1e+", "Expected a digit in the exponent", + "1E-", "Expected a digit in the exponent", + "[0,1e999]", "Number out of range at position 3", + "-1e999", "Number out of range", + "Infinity", "Unexpected character 'I'", + "NaN", "Unexpected character 'N'", + "0x10", "Unexpected text after the JSON value", + "\f1", "Unexpected character U+000C", + "\u000B1", "Unexpected character U+000B", + "\u00A01", "Unexpected character U+00A0", + "\u20281", "Unexpected character U+2028", + "[1,\u001C2]", "Unexpected character U+001C", + "{\"a\":1,\"a\":2}", "Duplicate member name \"a\" at position 7", + "{\"\\u0061\":1,\"a\":2}", "Duplicate member name \"a\"", + "{\"a\":1,\"b\":{},\"a\":null}", "Duplicate member name \"a\"", + "[1,]", "Unexpected character ']'", + "{\"a\":1,}", "Expected a member name", + "{'a':1}", "Expected a member name", + "{\"a\" 1}", "Expected ':'", + "[1 2]", "Expected ',' or ']'", + "[1]//", "Unexpected text after the JSON value", + "tru", "Expected 'true'", + "", "Unexpected end of JSON", + " ", "Unexpected end of JSON"); + } + + @TestFactory + List validJsonAccepted() { + Map object = new LinkedHashMap<>(); + object.put("a", Collections.singletonMap("a", 1.0)); + + // Input, then the value it is read as. + return accepted( + "0", 0.0, + "-0", -0.0, + "1.5e3", 1500.0, + "1E-2", 0.01, + "-12.25E+1", -122.5, + "1e-400", 0.0, + "1.7976931348623157e308", Double.MAX_VALUE, + "\"\\u0041\\u00e9\\uD83D\\uDE00\\/\"", "A\u00e9\uD83D\uDE00/", + "\"\u007F\u00e4\u2028\"", "\u007F\u00e4\u2028", + " \t\n\r[ 1 , {} ] \r\n", Arrays.asList(1.0, Collections.emptyMap()), + "{\"a\":{\"a\":1}}", object, + "null", null); + } + + @Test + void numbersFollowGrammar() { + // Every text of up to six of these chars, which make up every part of + // the grammar and most of the ways to get it wrong. + char[] alphabet = "-+.eE019".toCharArray(); + List texts = new ArrayList<>(); + texts.add(""); + + for (int length = 1, from = 0; length <= 6; length++) { + int to = texts.size(); + + for (int i = from; i < to; i++) { + for (char c : alphabet) { + texts.add(texts.get(i) + c); + } + } + + from = to; + } + + // And longer ones, which reach the exponents that overflow a double. + Random random = new Random(1); + + for (int i = 0; i < 20_000; i++) { + char[] text = new char[7 + random.nextInt(18)]; + + for (int j = 0; j < text.length; j++) { + text[j] = alphabet[random.nextInt(alphabet.length)]; + } + + texts.add(new String(text)); + } + + for (String text : texts) { + if (NUMBER.matcher(text).matches() && Double.isFinite(Double.parseDouble(text))) { + assertEquals(Double.valueOf(text), Json.parse(text), show(text)); + } + else { + assertRejected(text); + } + } + } + + @Test + void stringCharsFollowGrammar() { + for (int i = 0; i <= Character.MAX_VALUE; i++) { + char c = (char) i; + String raw = "\"" + c + "\""; + + if (c >= 0x20 && c != '"' && c != '\\') { + assertEquals(String.valueOf(c), Json.parse(raw), show(raw)); + } + else { + assertRejected(raw); + } + + assertEquals(String.valueOf(c), Json.parse(write(String.valueOf(c))), show(raw)); + } + } + + @Test + void escapesFollowGrammar() { + for (int i = 0; i <= Character.MAX_VALUE; i++) { + char c = (char) i; + String escape = "\"\\" + c + "\""; + + if ("\"\\/bfnrt".indexOf(c) >= 0) { + assertEquals(1, ((String) Json.parse(escape)).length(), show(escape)); + } + else { + assertRejected(escape); + } + + // Only ASCII hex digits, not signs nor digits of other scripts. + String unicode = "\"\\u00" + c + "1\""; + int digit = "0123456789abcdef".indexOf(Character.toLowerCase(c)); + + if (c < 0x80 && digit >= 0) { + assertEquals(String.valueOf((char) (digit << 4 | 1)), Json.parse(unicode), show(unicode)); + } + else { + assertRejected(unicode); + } + } + } + + @Test + void whitespaceFollowsGrammar() { + for (int i = 0; i <= Character.MAX_VALUE; i++) { + char c = (char) i; + String array = "[" + c + "]"; + + if (" \t\n\r".indexOf(c) >= 0) { + assertEquals(Collections.emptyList(), Json.parse(array), show(array)); + } + else if (c >= '0' && c <= '9') { + assertEquals(Collections.singletonList((double) (c - '0')), Json.parse(array), show(array)); + } + else { + assertRejected(array); + } + } + } + + @Test + void generatedDocumentsRoundTrip() { + Random random = new Random(2); + + for (int i = 0; i < 2_000; i++) { + Object value = randomValue(random, 0); + StringBuilder out = new StringBuilder(); + write(out, value, random); + + assertEquals(value, Json.parse(out.toString()), show(out.toString())); + } + } + + private static List rejected(String... cases) { + List tests = new ArrayList<>(); + + for (int i = 0; i < cases.length; i += 2) { + String text = cases[i]; + String message = cases[i + 1]; + + tests.add(DynamicTest.dynamicTest("`" + show(text) + "`", () -> { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> Json.parse(text)); + + assertTrue(e.getMessage().startsWith(message), e.getMessage()); + })); + } + + return tests; + } + + private static List accepted(Object... cases) { + List tests = new ArrayList<>(); + + for (int i = 0; i < cases.length; i += 2) { + String text = (String) cases[i]; + Object value = cases[i + 1]; + + tests.add(DynamicTest.dynamicTest("`" + show(text) + "`", () -> assertEquals(value, Json.parse(text)))); + } + + return tests; + } + + private static void assertRejected(String text) { + try { + Object value = Json.parse(text); + + fail(show(text) + " was read as " + (value instanceof String ? show((String) value) : value)); + } + catch (IllegalArgumentException e) { + // Rejected, as it should be. + } + } + + private static Object randomValue(Random random, int depth) { + switch (random.nextInt(depth < 4 ? 7 : 5)) { + case 0: + return null; + case 1: + return random.nextBoolean(); + case 2: + double d = Double.longBitsToDouble(random.nextLong()); + return Double.isFinite(d) ? d : (double) random.nextInt(); + case 3: + return (double) random.nextInt(1000) - 500; + case 4: + return randomString(random); + case 5: + List array = new ArrayList<>(); + for (int i = random.nextInt(4); i > 0; i--) { + array.add(randomValue(random, depth + 1)); + } + return array; + default: + Map object = new LinkedHashMap<>(); + for (int i = random.nextInt(4); i > 0; i--) { + object.put(randomString(random), randomValue(random, depth + 1)); + } + return object; + } + } + + private static String randomString(Random random) { + // Half of them the chars that need care: controls, quotes, backslashes. + String tricky = "\u0000\u001F\"\\/ \n\r\taZ\u007F\u2028\uD83D"; + char[] chars = new char[random.nextInt(12)]; + + for (int i = 0; i < chars.length; i++) { + chars[i] = random.nextBoolean() + ? tricky.charAt(random.nextInt(tricky.length())) + : (char) random.nextInt(Character.MAX_VALUE + 1); + } + + return new String(chars); + } + + private static void write(StringBuilder out, Object value, Random random) { + space(out, random); + + if (value instanceof String) { + Json.writeString(out, (String) value); + } + else if (value instanceof List) { + out.append('['); + String separator = ""; + + for (Object item : (List) value) { + out.append(separator); + write(out, item, random); + separator = ","; + } + + space(out, random); + out.append(']'); + } + else if (value instanceof Map) { + out.append('{'); + String separator = ""; + + for (Map.Entry member : ((Map) value).entrySet()) { + out.append(separator); + space(out, random); + Json.writeString(out, (String) member.getKey()); + space(out, random); + out.append(':'); + write(out, member.getValue(), random); + separator = ","; + } + + space(out, random); + out.append('}'); + } + else { + // Double.toString writes valid JSON numbers for finite values, + // such as -1.0E-5. + out.append(value); + } + + space(out, random); + } + + private static void space(StringBuilder out, Random random) { + for (int i = random.nextInt(3); i > 0; i--) { + out.append(" \t\n\r".charAt(random.nextInt(4))); + } + } + + private static String write(String value) { + StringBuilder out = new StringBuilder(); + Json.writeString(out, value); + + return out.toString(); + } + + /** + * @return the text, readable in a test name or message. + */ + private static String show(String text) { + StringBuilder out = new StringBuilder(); + + for (char c : text.toCharArray()) { + if (c >= 0x20 && c < 0x7f) { + out.append(c); + } + else { + out.append(String.format("\\u%04X", (int) c)); + } + } + + return out.toString(); + } + +} diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/SignalMessageTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/SignalMessageTests.java index aa0c1534..71691e48 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/SignalMessageTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/SignalMessageTests.java @@ -19,19 +19,28 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import dev.onvoid.webrtc.RTCIceCandidate; import dev.onvoid.webrtc.RTCSdpType; import dev.onvoid.webrtc.RTCSessionDescription; +import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; class SignalMessageTests { private static final String SDP = "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\ns=\"quoted\" \\ \u0001\r\n"; + /** A candidate message, with its sdpMLineIndex left to fill in. */ + private static final String CANDIDATE = + "{\"candidate\":\"candidate:1 1 udp 1 10.0.0.1 9 typ host\",\"sdpMid\":\"0\",\"sdpMLineIndex\":%s}"; + @Test void descriptionRoundTrip() { SignalMessage offer = SignalMessage.of(new RTCSessionDescription(RTCSdpType.OFFER, SDP)); @@ -84,6 +93,86 @@ void invalidMessagesRejected() { () -> SignalMessage.of(new RTCSessionDescription(RTCSdpType.ROLLBACK, ""))); } + @TestFactory + List invalidMLineIndexRejected() { + // Index, then the start of the message it fails with. The parser + // checks the syntax and that a number is finite, the message that it + // is an integer in range. + String[] cases = { + "1.9", "\"sdpMLineIndex\" is not an integer: 1.9", + "0.5", "\"sdpMLineIndex\" is not an integer", + "-0.5", "\"sdpMLineIndex\" is not an integer", + "1.0000001", "\"sdpMLineIndex\" is not an integer", + "-1", "\"sdpMLineIndex\" is not from 0 to 65535: -1", + "65536", "\"sdpMLineIndex\" is not from 0 to 65535: 65536", + "4294967296", "\"sdpMLineIndex\" is not from 0 to 65535: 4294967296", + "1e300", "\"sdpMLineIndex\" is not from 0 to 65535: 1.0E300", + "1e999", "Number out of range", + "01", "Leading zero in a number", + "1.", "Expected a digit after the decimal point", + "\"1\"", "\"sdpMLineIndex\" is not a number", + "true", "\"sdpMLineIndex\" is not a number", + "[0]", "\"sdpMLineIndex\" is not a number" + }; + List tests = new ArrayList<>(); + + for (int i = 0; i < cases.length; i += 2) { + String index = cases[i]; + String message = cases[i + 1]; + + tests.add(DynamicTest.dynamicTest(index, () -> { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> SignalMessage.fromJson(String.format(CANDIDATE, index))); + + assertTrue(e.getMessage().startsWith(message), e.getMessage()); + })); + } + + return tests; + } + + @TestFactory + List validMLineIndexAccepted() { + String[] indexes = { "0", "-0", "65535", "1.0", "1e2", "2E+1", "1000e-1" }; + int[] expected = { 0, 0, 65535, 1, 100, 20, 100 }; + List tests = new ArrayList<>(); + + for (int i = 0; i < indexes.length; i++) { + String index = indexes[i]; + int value = expected[i]; + + tests.add(DynamicTest.dynamicTest(index, () -> assertEquals(value, + SignalMessage.fromJson(String.format(CANDIDATE, index)).candidate().sdpMLineIndex))); + } + + return tests; + } + + @Test + void strictJsonRequired() { + // A browser's JSON.stringify escapes the line breaks of an SDP. + IllegalArgumentException rawBreak = assertThrows(IllegalArgumentException.class, + () -> SignalMessage.fromJson("{\"type\":\"offer\",\"sdp\":\"v=0\r\n\"}")); + // Which of the two counts would depend on the parser. + IllegalArgumentException twoTypes = assertThrows(IllegalArgumentException.class, + () -> SignalMessage.fromJson("{\"type\":\"offer\",\"type\":\"answer\",\"sdp\":\"v=0\\r\\n\"}")); + + assertTrue(rawBreak.getMessage().startsWith("Unescaped control character U+000D"), rawBreak.getMessage()); + assertTrue(twoTypes.getMessage().startsWith("Duplicate member name \"type\""), twoTypes.getMessage()); + } + + @Test + void unreadableIndexNotWritten() { + // A removed candidate has the index -1, which fromJson would reject. + assertThrows(IllegalArgumentException.class, + () -> SignalMessage.of(new RTCIceCandidate("0", -1, "candidate:1 1 udp 1 10.0.0.1 9 typ host"))); + assertThrows(IllegalArgumentException.class, + () -> SignalMessage.of(new RTCIceCandidate("0", 65536, "candidate:1 1 udp 1 10.0.0.1 9 typ host"))); + assertEquals(65535, SignalMessage.fromJson(SignalMessage.of( + new RTCIceCandidate("0", 65535, "candidate:1 1 udp 1 10.0.0.1 9 typ host")).toJson()) + .candidate().sdpMLineIndex); + } + @Test void deepNestingRejected() { // In a member the message does not know, which is read all the same.