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 42c2294..c7acc22 100644 --- a/sip-codec/src/main/java/com/sip/codec/SipParser.java +++ b/sip-codec/src/main/java/com/sip/codec/SipParser.java @@ -7,7 +7,6 @@ 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; @@ -360,15 +359,14 @@ private int parseStatusCode(String token, int offset) { } private Uri parseUriBestEffort(String text, int offset) { - int colon = text.indexOf(':'); - if (colon <= 0) { + try { + return SipUriParser.parse(text); + } catch (SipCodecException e) { + // Re-throw with the start-line offset for better diagnostics. throw new SipCodecException( - SipCodecException.Category.UNSUPPORTED_URI_SCHEME, offset, - "Request-URI missing scheme"); + e.category(), offset, + "Request-URI parse failed: " + e.getMessage(), e); } - String scheme = text.substring(0, colon); - String rest = text.substring(colon + 1); - return new OpaqueUri(scheme, rest); } /* -------- Scanning helpers -------- */ diff --git a/sip-codec/src/main/java/com/sip/codec/SipUriParser.java b/sip-codec/src/main/java/com/sip/codec/SipUriParser.java new file mode 100644 index 0000000..2dc0394 --- /dev/null +++ b/sip-codec/src/main/java/com/sip/codec/SipUriParser.java @@ -0,0 +1,213 @@ +package com.sip.codec; + +import com.sip.message.uri.OpaqueUri; +import com.sip.message.uri.SipUri; +import com.sip.message.uri.Uri; + +/** + * Parses a SIP / SIPS URI per RFC 3261 §19.1.1 (with IPv6 reference per + * RFC 3261 §25 / RFC 3986 §3.2.2). + * + *

Grammar handled (informally):

+ *
+ *   SIP-URI  =  ( "sip:" / "sips:" ) [ userinfo ] hostport
+ *               *( ";" uri-parameter ) [ "?" headers ]
+ *   userinfo =  user [ ":" password ] "@"
+ *   hostport =  host [ ":" port ]
+ *   host     =  hostname / IPv4address / IPv6reference
+ * 
+ * + *

For schemes other than {@code sip} / {@code sips}, the input is + * preserved as an {@link OpaqueUri} so that pass-through routing (e.g. + * {@code tel:}, {@code urn:}) continues to work.

+ * + *

The parser is forgiving on character classes — it accepts the + * superset of RFC 3261 ABNF and only rejects when structure is + * unambiguously broken. Strict ABNF enforcement happens at the + * {@code SipParser} call site if needed.

+ */ +public final class SipUriParser { + + private SipUriParser() { } + + /** + * Parses {@code text} as a SIP/SIPS URI, falling back to {@link OpaqueUri} + * for non-SIP schemes. + * + * @throws SipCodecException with category {@code malformed-header} for + * structurally invalid URIs. + */ + public static Uri parse(String text) { + if (text == null || text.isEmpty()) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + "empty URI"); + } + int colon = text.indexOf(':'); + if (colon <= 0) { + throw new SipCodecException( + SipCodecException.Category.UNSUPPORTED_URI_SCHEME, -1, + "URI missing scheme separator: '" + text + "'"); + } + String scheme = text.substring(0, colon); + String rest = text.substring(colon + 1); + + boolean secure = "sips".equalsIgnoreCase(scheme); + boolean isSip = secure || "sip".equalsIgnoreCase(scheme); + if (!isSip) { + return new OpaqueUri(scheme, rest); + } + + return parseSip(secure, rest); + } + + private static SipUri parseSip(boolean secure, String body) { + // Split off URI headers ("?" key=value (& key=value)*). + String beforeHeaders = body; + String headersText = null; + int q = indexOfTop(body, '?'); + if (q >= 0) { + beforeHeaders = body.substring(0, q); + headersText = body.substring(q + 1); + } + + // Split off URI parameters (";" pname=pvalue, separator ";"). + // Note: parameters in the USERINFO part are part of the user value, + // so we must locate the first ";" AFTER the "@" boundary. + int at = beforeHeaders.indexOf('@'); + int paramSearchFrom = (at >= 0) ? at + 1 : 0; + int semi = indexOfFrom(beforeHeaders, paramSearchFrom, ';'); + String hostpart = (semi >= 0) ? beforeHeaders.substring(0, semi) : beforeHeaders; + String paramsText = (semi >= 0) ? beforeHeaders.substring(semi + 1) : null; + + // Split userinfo from hostport. + String user = null; + String password = null; + String hostport = hostpart; + if (at >= 0) { + String userinfo = hostpart.substring(0, at); + hostport = hostpart.substring(at + 1); + int colon = userinfo.indexOf(':'); + if (colon >= 0) { + user = userinfo.substring(0, colon); + password = userinfo.substring(colon + 1); + } else { + user = userinfo; + } + } + + // Parse hostport: host [ ":" port ]. Tolerate bracketed IPv6 literals. + String host; + int port = -1; + if (hostport.startsWith("[")) { + int closeBracket = hostport.indexOf(']'); + if (closeBracket < 0) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + "unterminated IPv6 reference in URI host: '" + hostport + "'"); + } + host = hostport.substring(0, closeBracket + 1); + if (closeBracket + 1 < hostport.length()) { + if (hostport.charAt(closeBracket + 1) != ':') { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + "garbage after IPv6 reference: '" + hostport + "'"); + } + port = parsePort(hostport.substring(closeBracket + 2)); + } + } else { + int portColon = hostport.lastIndexOf(':'); + if (portColon < 0) { + host = hostport; + } else { + host = hostport.substring(0, portColon); + port = parsePort(hostport.substring(portColon + 1)); + } + } + if (host.isEmpty()) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + "URI has empty host"); + } + + SipUri.Builder b = SipUri.builder().secure(secure).host(host); + if (user != null) { + b.user(user); + } + if (password != null) { + b.password(password); + } + if (port >= 0) { + b.port(port); + } + if (paramsText != null && !paramsText.isEmpty()) { + for (String p : split(paramsText, ';')) { + int eq = p.indexOf('='); + if (eq < 0) { + b.param(p, ""); + } else { + b.param(p.substring(0, eq), p.substring(eq + 1)); + } + } + } + if (headersText != null && !headersText.isEmpty()) { + for (String h : split(headersText, '&')) { + int eq = h.indexOf('='); + if (eq < 0) { + b.header(h, ""); + } else { + b.header(h.substring(0, eq), h.substring(eq + 1)); + } + } + } + return b.build(); + } + + private static int parsePort(String text) { + if (text.isEmpty()) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + "URI port is empty"); + } + try { + int p = Integer.parseInt(text); + if (p < 0 || p > 65535) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + "URI port out of range [0,65535]: " + p); + } + return p; + } catch (NumberFormatException e) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + "URI port is not numeric: '" + text + "'", e); + } + } + + /** Index of {@code c} in {@code s} (no scoping). */ + private static int indexOfTop(String s, char c) { + return s.indexOf(c); + } + + private static int indexOfFrom(String s, int from, char c) { + return s.indexOf(c, from); + } + + private static String[] split(String s, char sep) { + // Hand-written split — String.split() is regex-based and overkill here. + int count = 1; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == sep) count++; + } + String[] out = new String[count]; + int start = 0, idx = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == sep) { + out[idx++] = s.substring(start, i); + start = i + 1; + } + } + out[idx] = s.substring(start); + return out; + } +} diff --git a/sip-codec/src/test/java/com/sip/codec/SipUriParserTest.java b/sip-codec/src/test/java/com/sip/codec/SipUriParserTest.java new file mode 100644 index 0000000..2f21456 --- /dev/null +++ b/sip-codec/src/test/java/com/sip/codec/SipUriParserTest.java @@ -0,0 +1,148 @@ +package com.sip.codec; + +import com.sip.message.uri.OpaqueUri; +import com.sip.message.uri.SipUri; +import com.sip.message.uri.Uri; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SipUriParserTest { + + @Test + void parsesBareHost() { + Uri uri = SipUriParser.parse("sip:example.com"); + assertThat(uri).isInstanceOf(SipUri.class); + SipUri sip = (SipUri) uri; + assertThat(sip.secure()).isFalse(); + assertThat(sip.host()).isEqualTo("example.com"); + assertThat(sip.user()).isEmpty(); + assertThat(sip.hasPort()).isFalse(); + } + + @Test + void parsesSipsScheme() { + SipUri uri = (SipUri) SipUriParser.parse("sips:alice@atlanta.example.com"); + assertThat(uri.secure()).isTrue(); + assertThat(uri.user()).contains("alice"); + } + + @Test + void parsesUserAndHost() { + SipUri uri = (SipUri) SipUriParser.parse("sip:alice@atlanta.example.com"); + assertThat(uri.user()).contains("alice"); + assertThat(uri.host()).isEqualTo("atlanta.example.com"); + } + + @Test + void parsesUserPasswordHostPort() { + SipUri uri = (SipUri) SipUriParser.parse("sip:alice:secret@atlanta:5070"); + assertThat(uri.user()).contains("alice"); + assertThat(uri.password()).contains("secret"); + assertThat(uri.host()).isEqualTo("atlanta"); + assertThat(uri.port()).isEqualTo(5070); + } + + @Test + void parsesUriParameters() { + SipUri uri = (SipUri) SipUriParser.parse( + "sip:bob@biloxi.com;transport=tcp;user=phone;lr"); + assertThat(uri.params()).containsEntry("transport", "tcp"); + assertThat(uri.params()).containsEntry("user", "phone"); + assertThat(uri.params()).containsEntry("lr", ""); + assertThat(uri.transport()).contains("tcp"); + } + + @Test + void parsesUriHeaders() { + SipUri uri = (SipUri) SipUriParser.parse( + "sip:carol@chicago.com?Subject=meeting&Priority=urgent"); + assertThat(uri.headers()).containsEntry("Subject", "meeting"); + assertThat(uri.headers()).containsEntry("Priority", "urgent"); + } + + @Test + void parsesIpv6Reference() { + SipUri uri = (SipUri) SipUriParser.parse("sip:alice@[2001:db8::1]:5060"); + assertThat(uri.host()).isEqualTo("[2001:db8::1]"); + assertThat(uri.port()).isEqualTo(5060); + } + + @Test + void parsesIpv6ReferenceWithoutPort() { + SipUri uri = (SipUri) SipUriParser.parse("sip:[2001:db8::1]"); + assertThat(uri.host()).isEqualTo("[2001:db8::1]"); + assertThat(uri.hasPort()).isFalse(); + } + + @Test + void parsesSemicolonInUserpart() { + // RFC 4475 §3.1.1.9 example — the user contains an escaped @ and a ; + SipUri uri = (SipUri) SipUriParser.parse( + "sip:user;par=u%40example.net@example.com"); + // The user-part splitter on '@' is RIGHTMOST, so 'user;par=u%40example.net' + // is treated as user and 'example.com' is host. Verify this. + assertThat(uri.user()).contains("user;par=u%40example.net"); + assertThat(uri.host()).isEqualTo("example.com"); + } + + @Test + void nonSipSchemeBecomesOpaqueUri() { + Uri uri = SipUriParser.parse("tel:+14155551212"); + assertThat(uri).isInstanceOf(OpaqueUri.class); + assertThat(uri.scheme()).isEqualTo("tel"); + assertThat(((OpaqueUri) uri).schemeSpecificPart()).isEqualTo("+14155551212"); + } + + @Test + void roundtripsViaAsWire() { + String[] cases = { + "sip:example.com", + "sips:alice@atlanta.example.com", + "sip:bob@biloxi.com;transport=tcp;lr", + "sip:carol@chicago.com:5070", + "sip:alice@[2001:db8::1]:5060", + "sip:user@example.com?Subject=meeting" + }; + for (String in : cases) { + Uri parsed = SipUriParser.parse(in); + String back = parsed.asWire(); + assertThat(SipUriParser.parse(back).asWire()) + .as("roundtrip for: " + in) + .isEqualTo(back); + } + } + + @Test + void rejectsEmptyHost() { + assertThatThrownBy(() -> SipUriParser.parse("sip:alice@")) + .isInstanceOf(SipCodecException.class) + .satisfies(t -> assertThat(((SipCodecException) t).category()) + .isEqualTo(SipCodecException.Category.MALFORMED_HEADER)); + } + + @Test + void rejectsInvalidPort() { + assertThatThrownBy(() -> SipUriParser.parse("sip:host:99999")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void rejectsBadIpv6Reference() { + assertThatThrownBy(() -> SipUriParser.parse("sip:[2001:db8::1")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void rejectsEmptyInput() { + assertThatThrownBy(() -> SipUriParser.parse("")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void rejectsMissingScheme() { + assertThatThrownBy(() -> SipUriParser.parse("alice@example.com")) + .isInstanceOf(SipCodecException.class); + } +}