Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions sip-codec/src/main/java/com/sip/codec/SipParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 -------- */
Expand Down
213 changes: 213 additions & 0 deletions sip-codec/src/main/java/com/sip/codec/SipUriParser.java
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>Grammar handled (informally):</p>
* <pre>
* SIP-URI = ( "sip:" / "sips:" ) [ userinfo ] hostport
* *( ";" uri-parameter ) [ "?" headers ]
* userinfo = user [ ":" password ] "@"
* hostport = host [ ":" port ]
* host = hostname / IPv4address / IPv6reference
* </pre>
*
* <p>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.</p>
*
* <p>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.</p>
*/
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;
}
}
148 changes: 148 additions & 0 deletions sip-codec/src/test/java/com/sip/codec/SipUriParserTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading