diff --git a/sip-codec/src/main/java/com/sip/codec/SipCodecException.java b/sip-codec/src/main/java/com/sip/codec/SipCodecException.java index 05ff791..bc3456f 100644 --- a/sip-codec/src/main/java/com/sip/codec/SipCodecException.java +++ b/sip-codec/src/main/java/com/sip/codec/SipCodecException.java @@ -1,22 +1,68 @@ package com.sip.codec; +import java.util.Objects; + /** * Thrown when wire-format bytes cannot be parsed into a valid SIP message, * or when a message cannot be serialized. * - *
The codec aims to be tolerant of common deviations described in - * RFC 5118 / RFC 4475, but malformed input that prevents safe interpretation - * must surface as an exception.
+ *Each exception carries a {@linkplain #category() stable category} that + * groups failure modes (e.g. {@code malformed-start-line}, + * {@code malformed-header}, {@code unknown-version}). The category is the + * same vocabulary that fixture {@code .expect.properties} files declare, + * so the compliance harness can assert end-to-end behaviour without coupling + * to free-form messages.
+ * + *The {@linkplain #offset() byte offset} points to the location in the + * input that triggered the failure (or {@code -1} when no specific offset + * applies, e.g. for trailing-data failures).
*/ public class SipCodecException extends RuntimeException { private static final long serialVersionUID = 1L; - public SipCodecException(String message) { - super(message); + /** Stable, lower-kebab-case taxonomy. Extend as new failure modes appear. */ + public static final class Category { + public static final String MALFORMED_START_LINE = "malformed-start-line"; + public static final String MALFORMED_HEADER = "malformed-header"; + public static final String UNKNOWN_VERSION = "unknown-version"; + public static final String BAD_CONTENT_LENGTH = "bad-content-length"; + public static final String TRUNCATED = "truncated"; + public static final String UNSUPPORTED_URI_SCHEME = "unsupported-uri-scheme"; + public static final String ENCODE_FAILURE = "encode-failure"; + + private Category() { } + } + + private final String category; + private final int offset; + + public SipCodecException(String category, int offset, String message) { + super(formatMessage(category, offset, message)); + this.category = Objects.requireNonNull(category, "category"); + this.offset = offset; + } + + public SipCodecException(String category, int offset, String message, Throwable cause) { + super(formatMessage(category, offset, message), cause); + this.category = Objects.requireNonNull(category, "category"); + this.offset = offset; + } + + /** Stable taxonomy bucket; see {@link Category} for known values. */ + public String category() { + return category; + } + + /** Byte offset of the failure within the input, or {@code -1} if unknown. */ + public int offset() { + return offset; } - public SipCodecException(String message, Throwable cause) { - super(message, cause); + private static String formatMessage(String category, int offset, String message) { + if (offset < 0) { + return "[" + category + "] " + message; + } + return "[" + category + " @ byte " + offset + "] " + message; } } diff --git a/sip-codec/src/main/java/com/sip/codec/SipParser.java b/sip-codec/src/main/java/com/sip/codec/SipParser.java index 3520840..42c2294 100644 --- a/sip-codec/src/main/java/com/sip/codec/SipParser.java +++ b/sip-codec/src/main/java/com/sip/codec/SipParser.java @@ -1,51 +1,427 @@ package com.sip.codec; import com.sip.message.SipMessage; +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import com.sip.message.SipVersion; +import com.sip.message.header.HeaderName; +import com.sip.message.header.Headers; +import com.sip.message.uri.OpaqueUri; +import com.sip.message.uri.Uri; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.util.Arrays; /** - * Hand-written SIP wire-format parser. + * Hand-written SIP wire-format parser (RFC 3261 §7, §25 ABNF). * - *This is the entry point used by transports. The full implementation - * will be built incrementally and gated by the RFC 4475 / 5118 torture- - * test suite that lives in the {@code sip-compliance-tests} module.
+ *The parser is intentionally structural: it produces a + * {@link SipMessage} whose headers are {@link com.sip.message.header.RawHeader + * RawHeader} instances. Typed header value parsing (Via params, From/To + * tags, CSeq method, …) is the responsibility of typed accessors added on + * top in subsequent iterations.
* - *Until each stage lands the parser throws {@link UnsupportedOperationException}. - * Test fixtures may still feed bytes here to lock in the public surface.
+ *All parse errors surface as {@link SipCodecException} carrying a + * {@link SipCodecException.Category stable category} and the byte offset + * of the failure for diagnostics.
*/ public final class SipParser { + private static final byte CR = '\r'; + private static final byte LF = '\n'; + private static final byte SP = ' '; + private static final byte HT = '\t'; + private static final byte COLON = ':'; + private SipParser() { } - /** - * Parses a complete SIP message from {@code bytes}, assuming the buffer - * contains exactly one message and the body length is taken from the - * {@code Content-Length} header. - */ + /** Parses a SIP message from a byte array. */ public static SipMessage parse(byte[] bytes) { - return parse(ByteBuffer.wrap(bytes)); + if (bytes == null) { + throw new SipCodecException( + SipCodecException.Category.TRUNCATED, -1, + "input is null"); + } + return new State(bytes).parseMessage(); } - /** UTF-8 / ASCII-friendly variant for tests and tracing. */ + /** Parses a SIP message from a string (UTF-8 encoded). */ public static SipMessage parse(String text) { + if (text == null) { + throw new SipCodecException( + SipCodecException.Category.TRUNCATED, -1, + "input is null"); + } return parse(text.getBytes(StandardCharsets.UTF_8)); } - /** Buffer-based variant; the parser does not retain the buffer. */ + /** Parses a SIP message from a {@link ByteBuffer}. The buffer is not retained. */ public static SipMessage parse(ByteBuffer buffer) { - throw new UnsupportedOperationException( - "SipParser is a scaffold; the parsing pipeline lands in the next iteration."); + if (buffer == null) { + throw new SipCodecException( + SipCodecException.Category.TRUNCATED, -1, + "input is null"); + } + byte[] copy = new byte[buffer.remaining()]; + buffer.duplicate().get(copy); + return parse(copy); + } + + /* --------------------------------------------------------------- */ + /* Internal scanner. One short-lived instance per parse() call. */ + /* --------------------------------------------------------------- */ + + private static final class State { + private final byte[] in; + private int pos; + + State(byte[] in) { + this.in = in; + this.pos = 0; + } + + SipMessage parseMessage() { + if (in.length < 4) { + throw new SipCodecException( + SipCodecException.Category.TRUNCATED, pos, + "input too short to contain a Start-Line"); + } + + boolean isResponse = startsWith(0, "SIP/"); + if (isResponse) { + return parseResponse(); + } + return parseRequest(); + } + + /* -------- Start-Line -------- */ + + private SipRequest parseRequest() { + // Request-Line = Method SP Request-URI SP SIP-Version CRLF + int methodStart = pos; + int firstSp = scanToByte(SP, "Request-Line missing SP after Method"); + if (firstSp == methodStart) { + throw startLineError("empty Method token"); + } + String methodName = ascii(methodStart, firstSp); + SipMethod method = parseMethodSafe(methodName, methodStart); + + int uriStart = firstSp + 1; + int secondSp = indexOf(SP, uriStart); + int eolCr = indexOfCrlf(uriStart); + if (secondSp < 0 || (eolCr >= 0 && secondSp > eolCr)) { + throw startLineError("Request-Line missing SIP-Version token"); + } + if (secondSp == uriStart) { + throw startLineError("empty Request-URI"); + } + String uriText = ascii(uriStart, secondSp); + Uri requestUri = parseUriBestEffort(uriText, uriStart); + + int versionStart = secondSp + 1; + int crlf = indexOfCrlf(versionStart); + if (crlf < 0) { + throw new SipCodecException( + SipCodecException.Category.TRUNCATED, versionStart, + "Request-Line not terminated by CRLF"); + } + String versionText = ascii(versionStart, crlf); + SipVersion version = parseVersion(versionText, versionStart); + + pos = crlf + 2; + + Headers headers = parseHeaderBlock(); + byte[] body = parseBody(headers); + + return new SipRequest(method, requestUri, version, headers, body); + } + + private SipResponse parseResponse() { + // Status-Line = SIP-Version SP Status-Code SP Reason-Phrase CRLF + int versionStart = pos; + int firstSp = scanToByte(SP, "Status-Line missing SP after SIP-Version"); + String versionText = ascii(versionStart, firstSp); + SipVersion version = parseVersion(versionText, versionStart); + + int statusStart = firstSp + 1; + int secondSp = indexOf(SP, statusStart); + int crlf = indexOfCrlf(statusStart); + if (secondSp < 0 || (crlf >= 0 && secondSp > crlf)) { + throw startLineError("Status-Line missing Reason-Phrase"); + } + String statusText = ascii(statusStart, secondSp); + int status = parseStatusCode(statusText, statusStart); + + int reasonStart = secondSp + 1; + int reasonEnd = indexOfCrlf(reasonStart); + if (reasonEnd < 0) { + throw new SipCodecException( + SipCodecException.Category.TRUNCATED, reasonStart, + "Status-Line not terminated by CRLF"); + } + String reason = utf8(reasonStart, reasonEnd); + + pos = reasonEnd + 2; + + Headers headers = parseHeaderBlock(); + byte[] body = parseBody(headers); + + return new SipResponse(version, status, reason, headers, body); + } + + /* -------- Headers -------- */ + + private Headers parseHeaderBlock() { + Headers.Builder builder = Headers.builder(); + StringBuilder valueBuf = new StringBuilder(64); + String pendingName = null; + int pendingNameStart = -1; + + while (pos < in.length) { + if (startsWith(pos, "\r\n")) { + // End of header block — empty line. + if (pendingName != null) { + builder.add(HeaderName.of(pendingName), valueBuf.toString().trim()); + pendingName = null; + } + pos += 2; + return builder.build(); + } + + byte first = in[pos]; + if ((first == SP || first == HT) && pendingName != null) { + // RFC 3261 §7.3.1 — continuation line; replace LWS with a single SP. + int crlf = indexOfCrlf(pos); + if (crlf < 0) { + throw new SipCodecException( + SipCodecException.Category.TRUNCATED, pos, + "folded header line missing CRLF"); + } + int contentStart = pos; + while (contentStart < crlf + && (in[contentStart] == SP || in[contentStart] == HT)) { + contentStart++; + } + valueBuf.append(' ').append(utf8(contentStart, crlf)); + pos = crlf + 2; + continue; + } + + if (pendingName != null) { + builder.add(HeaderName.of(pendingName), valueBuf.toString().trim()); + pendingName = null; + valueBuf.setLength(0); + } + + pendingNameStart = pos; + int colon = indexOf(COLON, pos); + int crlf = indexOfCrlf(pos); + if (colon < 0 || (crlf >= 0 && colon > crlf)) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, pendingNameStart, + "header line missing ':' separator"); + } + int nameEnd = colon; + while (nameEnd > pendingNameStart + && (in[nameEnd - 1] == SP || in[nameEnd - 1] == HT)) { + nameEnd--; + } + if (nameEnd == pendingNameStart) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, pendingNameStart, + "header line has empty name"); + } + pendingName = ascii(pendingNameStart, nameEnd); + + int valueStart = colon + 1; + while (valueStart < crlf + && (in[valueStart] == SP || in[valueStart] == HT)) { + valueStart++; + } + if (crlf < 0) { + throw new SipCodecException( + SipCodecException.Category.TRUNCATED, valueStart, + "header line missing CRLF"); + } + valueBuf.setLength(0); + valueBuf.append(utf8(valueStart, crlf)); + pos = crlf + 2; + } + + throw new SipCodecException( + SipCodecException.Category.TRUNCATED, pos, + "header block not terminated by CRLFCRLF"); + } + + /* -------- Body -------- */ + + private byte[] parseBody(Headers headers) { + int remaining = in.length - pos; + int declared = headers.first(HeaderName.CONTENT_LENGTH) + .map(h -> parseContentLength(h.value())) + .orElse(-1); + + if (declared < 0) { + if (remaining == 0) { + return new byte[0]; + } + byte[] body = new byte[remaining]; + System.arraycopy(in, pos, body, 0, remaining); + pos += remaining; + return body; + } + + if (declared > remaining) { + throw new SipCodecException( + SipCodecException.Category.BAD_CONTENT_LENGTH, pos, + "Content-Length " + declared + " exceeds available body bytes " + + remaining); + } + byte[] body = new byte[declared]; + System.arraycopy(in, pos, body, 0, declared); + pos += declared; + return body; + } + + private int parseContentLength(String text) { + String trimmed = text.trim(); + try { + int v = Integer.parseInt(trimmed); + if (v < 0) { + throw new SipCodecException( + SipCodecException.Category.BAD_CONTENT_LENGTH, -1, + "Content-Length is negative: " + v); + } + return v; + } catch (NumberFormatException e) { + throw new SipCodecException( + SipCodecException.Category.BAD_CONTENT_LENGTH, -1, + "Content-Length is not an integer: '" + trimmed + "'", e); + } + } + + /* -------- Token parsers -------- */ + + private SipMethod parseMethodSafe(String token, int offset) { + try { + return SipMethod.of(token); + } catch (IllegalArgumentException e) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_START_LINE, offset, + "invalid Method token '" + token + "': " + e.getMessage(), e); + } + } + + private SipVersion parseVersion(String token, int offset) { + if ("SIP/2.0".equals(token)) { + return SipVersion.SIP_2_0; + } + throw new SipCodecException( + SipCodecException.Category.UNKNOWN_VERSION, offset, + "unsupported SIP version '" + token + "'"); + } + + private int parseStatusCode(String token, int offset) { + if (token.length() != 3) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_START_LINE, offset, + "Status-Code must be exactly 3 digits, got '" + token + "'"); + } + int v = 0; + for (int i = 0; i < 3; i++) { + char c = token.charAt(i); + if (c < '0' || c > '9') { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_START_LINE, offset + i, + "Status-Code contains non-digit '" + c + "'"); + } + v = v * 10 + (c - '0'); + } + if (v < 100 || v > 699) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_START_LINE, offset, + "Status-Code out of range [100,699]: " + v); + } + return v; + } + + private Uri parseUriBestEffort(String text, int offset) { + int colon = text.indexOf(':'); + if (colon <= 0) { + throw new SipCodecException( + SipCodecException.Category.UNSUPPORTED_URI_SCHEME, offset, + "Request-URI missing scheme"); + } + String scheme = text.substring(0, colon); + String rest = text.substring(colon + 1); + return new OpaqueUri(scheme, rest); + } + + /* -------- Scanning helpers -------- */ + + private SipCodecException startLineError(String message) { + return new SipCodecException( + SipCodecException.Category.MALFORMED_START_LINE, pos, message); + } + + private boolean startsWith(int at, String token) { + byte[] needle = token.getBytes(StandardCharsets.US_ASCII); + if (at + needle.length > in.length) { + return false; + } + return Arrays.equals(in, at, at + needle.length, needle, 0, needle.length); + } + + private int indexOf(byte b, int from) { + for (int i = from; i < in.length; i++) { + if (in[i] == b) { + return i; + } + if (in[i] == CR || in[i] == LF) { + return -1; + } + } + return -1; + } + + /** Returns the offset of CR in the next CRLF after {@code from}, or -1. */ + private int indexOfCrlf(int from) { + for (int i = from; i + 1 < in.length; i++) { + if (in[i] == CR && in[i + 1] == LF) { + return i; + } + } + return -1; + } + + private int scanToByte(byte b, String onTruncated) { + int idx = indexOf(b, pos); + if (idx < 0) { + throw startLineError(onTruncated); + } + return idx; + } + + private String ascii(int start, int end) { + return new String(in, start, end - start, StandardCharsets.US_ASCII); + } + + private String utf8(int start, int end) { + return new String(in, start, end - start, StandardCharsets.UTF_8); + } } } diff --git a/sip-codec/src/test/java/com/sip/codec/SipParserContractTest.java b/sip-codec/src/test/java/com/sip/codec/SipParserContractTest.java deleted file mode 100644 index 5f55412..0000000 --- a/sip-codec/src/test/java/com/sip/codec/SipParserContractTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.sip.codec; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Locks in the public surface of the codec while the implementation is - * scaffolded. As soon as the parser implementation lands, these tests - * graduate to real behavioural assertions and the RFC 4475 fixtures - * (in the compliance-tests module) become the conformance gate. - */ -class SipParserContractTest { - - @Test - void parserSurfaceIsReachable() { - assertThatThrownBy(() -> SipParser.parse("OPTIONS sip:carol@chicago.com SIP/2.0\r\n\r\n")) - .isInstanceOf(UnsupportedOperationException.class); - } -} diff --git a/sip-codec/src/test/java/com/sip/codec/SipParserTest.java b/sip-codec/src/test/java/com/sip/codec/SipParserTest.java new file mode 100644 index 0000000..4d091dd --- /dev/null +++ b/sip-codec/src/test/java/com/sip/codec/SipParserTest.java @@ -0,0 +1,132 @@ +package com.sip.codec; + +import com.sip.message.SipMessage; +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import com.sip.message.SipVersion; +import com.sip.message.header.HeaderName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SipParserTest { + + @Test + void parsesMinimalOptionsRequest() { + String raw = + "OPTIONS sip:carol@example.com SIP/2.0\r\n" + + "Via: SIP/2.0/UDP host;branch=z9hG4bK1\r\n" + + "Max-Forwards: 70\r\n" + + "Content-Length: 0\r\n" + + "\r\n"; + + SipMessage msg = SipParser.parse(raw); + + assertThat(msg).isInstanceOf(SipRequest.class); + SipRequest req = (SipRequest) msg; + assertThat(req.method()).isEqualTo(SipMethod.OPTIONS); + assertThat(req.requestUri().asWire()).isEqualTo("sip:carol@example.com"); + assertThat(req.version()).isEqualTo(SipVersion.SIP_2_0); + assertThat(req.headers().size()).isEqualTo(3); + assertThat(req.body()).isEmpty(); + } + + @Test + void parsesMinimal200OkResponse() { + String raw = + "SIP/2.0 200 OK\r\n" + + "Via: SIP/2.0/UDP host;branch=z9hG4bK1\r\n" + + "Content-Length: 0\r\n" + + "\r\n"; + + SipMessage msg = SipParser.parse(raw); + + assertThat(msg).isInstanceOf(SipResponse.class); + SipResponse rsp = (SipResponse) msg; + assertThat(rsp.status()).isEqualTo(200); + assertThat(rsp.reason()).isEqualTo("OK"); + assertThat(rsp.isSuccess()).isTrue(); + assertThat(rsp.headers().size()).isEqualTo(2); + } + + @Test + void unfoldsContinuationHeaders() { + String raw = + "OPTIONS sip:u@e.com SIP/2.0\r\n" + + "Accept: application/sdp,\r\n" + + " application/json\r\n" + + "Content-Length: 0\r\n" + + "\r\n"; + + SipMessage msg = SipParser.parse(raw); + SipRequest req = (SipRequest) msg; + + assertThat(req.headers().first(HeaderName.of("Accept")).orElseThrow().value()) + .isEqualTo("application/sdp, application/json"); + assertThat(req.headers().size()).isEqualTo(2); + } + + @Test + void framesBodyByContentLength() { + byte[] body = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n".getBytes(); + String head = + "INVITE sip:u@e.com SIP/2.0\r\n" + + "Via: SIP/2.0/UDP host;branch=z9hG4bK1\r\n" + + "Content-Type: application/sdp\r\n" + + "Content-Length: " + body.length + "\r\n" + + "\r\n"; + + byte[] bytes = new byte[head.length() + body.length]; + System.arraycopy(head.getBytes(), 0, bytes, 0, head.length()); + System.arraycopy(body, 0, bytes, head.length(), body.length); + + SipRequest req = (SipRequest) SipParser.parse(bytes); + assertThat(req.method()).isEqualTo(SipMethod.INVITE); + assertThat(req.body()).isEqualTo(body); + } + + @Test + void rejectsMissingSipVersion() { + String raw = "OPTIONS sip:carol@example.com\r\n\r\n"; + assertThatThrownBy(() -> SipParser.parse(raw)) + .isInstanceOf(SipCodecException.class) + .satisfies(t -> { + SipCodecException e = (SipCodecException) t; + assertThat(e.category()) + .isEqualTo(SipCodecException.Category.MALFORMED_START_LINE); + }); + } + + @Test + void rejectsUnknownSipVersion() { + String raw = "OPTIONS sip:carol@example.com SIP/1.0\r\n\r\n"; + assertThatThrownBy(() -> SipParser.parse(raw)) + .isInstanceOf(SipCodecException.class) + .satisfies(t -> assertThat(((SipCodecException) t).category()) + .isEqualTo(SipCodecException.Category.UNKNOWN_VERSION)); + } + + @Test + void rejectsContentLengthLargerThanBody() { + String raw = + "OPTIONS sip:u@e.com SIP/2.0\r\n" + + "Content-Length: 100\r\n" + + "\r\n" + + "short"; + assertThatThrownBy(() -> SipParser.parse(raw)) + .isInstanceOf(SipCodecException.class) + .satisfies(t -> assertThat(((SipCodecException) t).category()) + .isEqualTo(SipCodecException.Category.BAD_CONTENT_LENGTH)); + } + + @Test + void rejectsTruncatedHeader() { + String raw = "OPT"; + assertThatThrownBy(() -> SipParser.parse(raw)) + .isInstanceOf(SipCodecException.class) + .satisfies(t -> assertThat(((SipCodecException) t).category()) + .isEqualTo(SipCodecException.Category.TRUNCATED)); + } +} diff --git a/sip-compliance-tests/src/test/java/com/sip/compliance/fixture/FixtureExpectation.java b/sip-compliance-tests/src/test/java/com/sip/compliance/fixture/FixtureExpectation.java index 0bf9746..08d40a5 100644 --- a/sip-compliance-tests/src/test/java/com/sip/compliance/fixture/FixtureExpectation.java +++ b/sip-compliance-tests/src/test/java/com/sip/compliance/fixture/FixtureExpectation.java @@ -29,6 +29,16 @@ record Accept( int headerCount, int bodyLength) implements FixtureExpectation { } - /** The parser must reject this fixture; the category groups the failure. */ - record Reject(String category, String detail) implements FixtureExpectation { } + /** + * The parser must reject this fixture; the category groups the failure. + * + * @param phase identifies WHICH layer of the parsing pipeline is + * expected to reject. {@code "structural"} (default) means + * the wire-format parser itself. Later phases — + * {@code "typed-header"}, {@code "transaction"}, + * {@code "dialog"} — fire only after richer parsing + * capabilities land. Tests for a given phase ignore + * fixtures whose rejection happens at a later phase. + */ + record Reject(String category, String detail, String phase) implements FixtureExpectation { } } diff --git a/sip-compliance-tests/src/test/java/com/sip/compliance/fixture/FixtureRepository.java b/sip-compliance-tests/src/test/java/com/sip/compliance/fixture/FixtureRepository.java index 24571ac..29d2fee 100644 --- a/sip-compliance-tests/src/test/java/com/sip/compliance/fixture/FixtureRepository.java +++ b/sip-compliance-tests/src/test/java/com/sip/compliance/fixture/FixtureRepository.java @@ -150,7 +150,8 @@ private static FixtureExpectation.Accept parseAccept(String id, Properties p, in private static FixtureExpectation.Reject parseReject(String id, Properties p) { String category = required(p, "error.category", id); String detail = p.getProperty("error.detail", ""); - return new FixtureExpectation.Reject(category, detail); + String phase = p.getProperty("parser.phase", "structural").trim(); + return new FixtureExpectation.Reject(category, detail, phase); } private static MessageKind parseKind(String id, String raw) { diff --git a/sip-compliance-tests/src/test/java/com/sip/compliance/parser/ParserConformanceTest.java b/sip-compliance-tests/src/test/java/com/sip/compliance/parser/ParserConformanceTest.java new file mode 100644 index 0000000..34d9753 --- /dev/null +++ b/sip-compliance-tests/src/test/java/com/sip/compliance/parser/ParserConformanceTest.java @@ -0,0 +1,109 @@ +package com.sip.compliance.parser; + +import com.sip.codec.SipCodecException; +import com.sip.codec.SipParser; +import com.sip.compliance.fixture.FixtureExpectation; +import com.sip.compliance.fixture.FixtureRepository; +import com.sip.compliance.fixture.MessageKind; +import com.sip.compliance.fixture.TortureFixture; +import com.sip.message.SipMessage; +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +/** + * Drives every torture fixture through {@link SipParser} and asserts the + * verdict the fixture declares. + * + *This is the gate that keeps the parser honest: every commit that + * touches the parser must keep this test green. New fixtures (RFC 4475, + * RFC 5118, GB28181 vectors, …) are picked up automatically — drop the + * paired {@code .raw} + {@code .expect.properties} into + * {@code torture/} and they become part of the gate.
+ */ +class ParserConformanceTest { + + private static final ListThe output of this method round-trips through the URI parser + * for any URI the parser accepted, modulo whitespace normalisation + * described in RFC 3261 §19.1.4.
+ */ + String asWire(); }