diff --git a/sip-codec/src/main/java/com/sip/codec/typed/CSeqParser.java b/sip-codec/src/main/java/com/sip/codec/typed/CSeqParser.java new file mode 100644 index 0000000..c884770 --- /dev/null +++ b/sip-codec/src/main/java/com/sip/codec/typed/CSeqParser.java @@ -0,0 +1,82 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.SipMethod; +import com.sip.message.header.HeaderName; +import com.sip.message.header.Headers; +import com.sip.message.header.RawHeader; +import com.sip.message.header.typed.CSeqHeader; + +import java.util.Optional; + +/** + * Parses the CSeq header (RFC 3261 §20.16): + *
+ *   CSeq  =  1*DIGIT LWS Method
+ * 
+ */ +public final class CSeqParser { + + private CSeqParser() { } + + public static Optional parse(Headers headers) { + return headers.first(HeaderName.CSEQ).map(CSeqParser::parse); + } + + public static CSeqHeader parse(RawHeader raw) { + return parse(raw.value()); + } + + public static CSeqHeader parse(String text) { + if (text == null) { + throw malformed("CSeq is null"); + } + String s = text.trim(); + int sp = -1; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == ' ' || c == '\t') { + sp = i; + break; + } + } + if (sp <= 0) { + throw malformed("CSeq missing whitespace between sequence and method: '" + s + "'"); + } + String seqText = s.substring(0, sp); + long sequence; + try { + sequence = Long.parseLong(seqText); + } catch (NumberFormatException e) { + throw malformed("CSeq sequence is not numeric: '" + seqText + "'", e); + } + if (sequence < 0 || sequence > CSeqHeader.MAX_SEQUENCE) { + throw malformed("CSeq sequence out of range [0," + CSeqHeader.MAX_SEQUENCE + + "]: " + sequence); + } + int methodStart = sp; + while (methodStart < s.length() + && (s.charAt(methodStart) == ' ' || s.charAt(methodStart) == '\t')) { + methodStart++; + } + if (methodStart == s.length()) { + throw malformed("CSeq missing Method token"); + } + String methodText = s.substring(methodStart).trim(); + try { + return new CSeqHeader(sequence, SipMethod.of(methodText)); + } catch (IllegalArgumentException e) { + throw malformed("CSeq Method invalid: " + e.getMessage(), e); + } + } + + private static SipCodecException malformed(String message) { + return new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, message); + } + + private static SipCodecException malformed(String message, Throwable cause) { + return new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, message, cause); + } +} diff --git a/sip-codec/src/main/java/com/sip/codec/typed/CallIdValidator.java b/sip-codec/src/main/java/com/sip/codec/typed/CallIdValidator.java new file mode 100644 index 0000000..b522789 --- /dev/null +++ b/sip-codec/src/main/java/com/sip/codec/typed/CallIdValidator.java @@ -0,0 +1,59 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.header.HeaderName; +import com.sip.message.header.Headers; + +import java.util.Optional; + +/** + * Validates Call-ID per RFC 3261 §20.8 / §25: + *
+ *   Call-ID  =  word [ "@" word ]
+ *   word     =  1*(alphanum / "-" / "." / "!" / "%" / "*" / "_" / "+"
+ *                  / "`" / "'" / "~" / "(" / ")" / "<" / ">"
+ *                  / ":" / "\" / DQUOTE / "/" / "[" / "]" / "?" / "{" / "}")
+ * 
+ * + *

Returns the validated string (after trimming surrounding LWS) for use + * as a dialog identity component.

+ */ +public final class CallIdValidator { + + private CallIdValidator() { } + + public static Optional get(Headers headers) { + return headers.first(HeaderName.CALL_ID).map(r -> validate(r.value())); + } + + public static String validate(String text) { + String s = text == null ? "" : text.trim(); + if (s.isEmpty()) { + throw malformed("Call-ID is empty"); + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (!isWordChar(c) && c != '@') { + throw malformed("Call-ID contains invalid character at offset " + i + + ": '" + c + "' (0x" + Integer.toHexString(c) + ")"); + } + } + return s; + } + + private static boolean isWordChar(char c) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + return true; + } + return switch (c) { + case '-', '.', '!', '%', '*', '_', '+', '`', '\'', '~', + '(', ')', '<', '>', ':', '\\', '"', '/', '[', ']', '?', '{', '}' -> true; + default -> false; + }; + } + + private static SipCodecException malformed(String message) { + return new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, message); + } +} diff --git a/sip-codec/src/main/java/com/sip/codec/typed/ContactParser.java b/sip-codec/src/main/java/com/sip/codec/typed/ContactParser.java new file mode 100644 index 0000000..e8bfeba --- /dev/null +++ b/sip-codec/src/main/java/com/sip/codec/typed/ContactParser.java @@ -0,0 +1,53 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.header.HeaderName; +import com.sip.message.header.Headers; +import com.sip.message.header.RawHeader; +import com.sip.message.header.typed.ContactValue; + +import java.util.ArrayList; +import java.util.List; + +/** + * Parses Contact header values (RFC 3261 §20.10): + * + *
+ *   Contact          =  ("Contact" / "m") HCOLON
+ *                       ( STAR / (contact-param *(COMMA contact-param)) )
+ *   contact-param    =  (name-addr / addr-spec) *(SEMI contact-params)
+ * 
+ * + *

Returns either a single wildcard entry or a list of named contacts.

+ */ +public final class ContactParser { + + private ContactParser() { } + + public static List parseAll(Headers headers) { + List out = new ArrayList<>(); + boolean sawWildcard = false; + for (RawHeader raw : headers.all(HeaderName.CONTACT)) { + String v = raw.value().trim(); + if ("*".equals(v)) { + if (!out.isEmpty()) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + "Contact: '*' cannot be combined with other contact values"); + } + sawWildcard = true; + out.add(ContactValue.wildcard()); + continue; + } + if (sawWildcard) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + "additional Contact values after wildcard '*'"); + } + for (var na : NameAddrParser.parseList(v)) { + out.add(ContactValue.of(na)); + } + } + return out; + } +} diff --git a/sip-codec/src/main/java/com/sip/codec/typed/IntegerHeaderParser.java b/sip-codec/src/main/java/com/sip/codec/typed/IntegerHeaderParser.java new file mode 100644 index 0000000..768a6f5 --- /dev/null +++ b/sip-codec/src/main/java/com/sip/codec/typed/IntegerHeaderParser.java @@ -0,0 +1,48 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.header.HeaderName; +import com.sip.message.header.Headers; + +import java.util.Optional; + +/** + * Helpers for simple integer headers (Max-Forwards, Expires, Content-Length). + */ +public final class IntegerHeaderParser { + + private IntegerHeaderParser() { } + + public static Optional maxForwards(Headers h) { + return h.first(HeaderName.MAX_FORWARDS).map(r -> parseInt(r.value(), 0, 255, + "Max-Forwards")); + } + + public static Optional expires(Headers h) { + return h.first(HeaderName.EXPIRES).map(r -> parseInt(r.value(), 0, Integer.MAX_VALUE, + "Expires")); + } + + public static Optional contentLength(Headers h) { + return h.first(HeaderName.CONTENT_LENGTH).map(r -> parseInt(r.value(), 0, + Integer.MAX_VALUE, "Content-Length")); + } + + static int parseInt(String text, int min, int max, String headerName) { + String s = text.trim(); + int v; + try { + v = Integer.parseInt(s); + } catch (NumberFormatException e) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + headerName + " is not an integer: '" + s + "'", e); + } + if (v < min || v > max) { + throw new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, + headerName + " out of range [" + min + "," + max + "]: " + v); + } + return v; + } +} diff --git a/sip-codec/src/main/java/com/sip/codec/typed/NameAddrParser.java b/sip-codec/src/main/java/com/sip/codec/typed/NameAddrParser.java new file mode 100644 index 0000000..07168f7 --- /dev/null +++ b/sip-codec/src/main/java/com/sip/codec/typed/NameAddrParser.java @@ -0,0 +1,168 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.codec.SipUriParser; +import com.sip.message.header.typed.NameAddr; +import com.sip.message.uri.Uri; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Parses a single {@code name-addr} or {@code addr-spec} (RFC 3261 §25) + * followed by zero or more header parameters. + * + *
+ *   name-addr        =  [ display-name ] LAQUOT addr-spec RAQUOT
+ *   addr-spec        =  SIP-URI / SIPS-URI / absoluteURI
+ *   from-spec        =  ( name-addr / addr-spec ) *( SEMI from-param )
+ * 
+ * + *

The parser is tolerant of several minor irregularities described in + * RFC 4475 §3.1.1 — most importantly, missing LWS between the display-name + * and the {@code <} (RFC 4475 §3.1.1.6 / {@code lwsdisp}).

+ */ +public final class NameAddrParser { + + private NameAddrParser() { } + + /** Parses one {@code name-addr [params]} value. */ + public static NameAddr parseSingle(String text) { + if (text == null) { + throw malformed("empty name-addr"); + } + String s = text.trim(); + if (s.isEmpty()) { + throw malformed("empty name-addr"); + } + + String displayName = null; + Uri uri; + String paramsText = null; + + if (s.startsWith("\"")) { + // Quoted display name. + int closing = findClosingQuote(s, 1); + if (closing < 0) { + throw malformed("unterminated quoted-string in display-name: '" + s + "'"); + } + displayName = unescape(s.substring(1, closing)); + int laquot = s.indexOf('<', closing + 1); + if (laquot < 0) { + throw malformed("display-name missing '' after quoted string"); + } + int raquot = s.indexOf('>', laquot + 1); + if (raquot < 0) { + throw malformed("missing '>' after addr-spec"); + } + uri = SipUriParser.parse(s.substring(laquot + 1, raquot).trim()); + if (raquot + 1 < s.length() && s.charAt(raquot + 1) == ';') { + paramsText = s.substring(raquot + 2); + } + } else if (s.startsWith("<")) { + // Bare addr-spec in angle brackets. + int raquot = s.indexOf('>', 1); + if (raquot < 0) { + throw malformed("missing '>' after addr-spec"); + } + uri = SipUriParser.parse(s.substring(1, raquot).trim()); + if (raquot + 1 < s.length() && s.charAt(raquot + 1) == ';') { + paramsText = s.substring(raquot + 2); + } + } else { + int laquot = s.indexOf('<'); + if (laquot >= 0) { + // Token display-name followed by ''. Allows zero-LWS form + // (RFC 4475 §3.1.1.6 lwsdisp). + displayName = s.substring(0, laquot).trim(); + if (displayName.isEmpty()) { + displayName = null; + } + int raquot = s.indexOf('>', laquot + 1); + if (raquot < 0) { + throw malformed("missing '>' after addr-spec"); + } + uri = SipUriParser.parse(s.substring(laquot + 1, raquot).trim()); + if (raquot + 1 < s.length() && s.charAt(raquot + 1) == ';') { + paramsText = s.substring(raquot + 2); + } + } else { + // Bare addr-spec form: split off optional ;params. + int semi = s.indexOf(';'); + String uriText = (semi < 0) ? s : s.substring(0, semi); + uri = SipUriParser.parse(uriText.trim()); + if (semi >= 0) { + paramsText = s.substring(semi + 1); + } + } + } + + Map params = new LinkedHashMap<>(); + if (paramsText != null && !paramsText.isEmpty()) { + for (String p : ViaParser.splitParams(paramsText)) { + int eq = p.indexOf('='); + if (eq < 0) { + String name = p.trim(); + if (name.isEmpty()) { + throw malformed("empty parameter token in '" + paramsText + "'"); + } + params.put(name, ""); + } else { + String name = p.substring(0, eq).trim(); + String value = p.substring(eq + 1).trim(); + if (name.isEmpty()) { + throw malformed("empty parameter name in '" + p + "'"); + } + params.put(name, value); + } + } + } + return new NameAddr(java.util.Optional.ofNullable(displayName), uri, params); + } + + /** Parses a comma-separated list of {@code name-addr [params]} values. */ + public static List parseList(String text) { + List out = new ArrayList<>(); + for (String part : ViaParser.splitTopLevelCommas(text)) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + out.add(parseSingle(trimmed)); + } + } + return out; + } + + private static int findClosingQuote(String s, int from) { + for (int i = from; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\') { + i++; // skip escaped char + continue; + } + if (c == '"') { + return i; + } + } + return -1; + } + + private static String unescape(String s) { + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\' && i + 1 < s.length()) { + sb.append(s.charAt(++i)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static SipCodecException malformed(String message) { + return new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, message); + } +} diff --git a/sip-codec/src/main/java/com/sip/codec/typed/RouteSetParser.java b/sip-codec/src/main/java/com/sip/codec/typed/RouteSetParser.java new file mode 100644 index 0000000..c77ca2b --- /dev/null +++ b/sip-codec/src/main/java/com/sip/codec/typed/RouteSetParser.java @@ -0,0 +1,39 @@ +package com.sip.codec.typed; + +import com.sip.message.header.HeaderName; +import com.sip.message.header.Headers; +import com.sip.message.header.RawHeader; +import com.sip.message.header.typed.NameAddr; + +import java.util.ArrayList; +import java.util.List; + +/** + * Parses Route (RFC 3261 §20.34) and Record-Route (§20.30) header lists. + * + *

Both headers carry a comma-separated list of {@code name-addr}s. + * Multiple header lines are equivalent to a single header line with the + * values concatenated by commas, in order.

+ */ +public final class RouteSetParser { + + private RouteSetParser() { } + + /** Parse the Route header set, preserving wire order. */ + public static List route(Headers headers) { + return collect(headers, HeaderName.ROUTE); + } + + /** Parse the Record-Route header set, preserving wire order. */ + public static List recordRoute(Headers headers) { + return collect(headers, HeaderName.RECORD_ROUTE); + } + + private static List collect(Headers headers, HeaderName name) { + List out = new ArrayList<>(); + for (RawHeader raw : headers.all(name)) { + out.addAll(NameAddrParser.parseList(raw.value())); + } + return out; + } +} diff --git a/sip-codec/src/main/java/com/sip/codec/typed/ViaParser.java b/sip-codec/src/main/java/com/sip/codec/typed/ViaParser.java new file mode 100644 index 0000000..ebeba08 --- /dev/null +++ b/sip-codec/src/main/java/com/sip/codec/typed/ViaParser.java @@ -0,0 +1,206 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.header.HeaderName; +import com.sip.message.header.Headers; +import com.sip.message.header.RawHeader; +import com.sip.message.header.typed.HostPort; +import com.sip.message.header.typed.ViaHeader; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Parses Via header field values per RFC 3261 §20.42 / §25. + * + *

A single Via header may contain multiple comma-separated + * {@code via-parm}s; equivalently, repeated Via headers may appear in the + * message. This parser handles both forms transparently.

+ */ +public final class ViaParser { + + private ViaParser() { } + + /** Parses all Via header field values across all Via headers in the message. */ + public static List parseAll(Headers headers) { + List out = new ArrayList<>(); + for (RawHeader raw : headers.all(HeaderName.VIA)) { + for (String value : splitTopLevelCommas(raw.value())) { + out.add(parseSingle(value.trim())); + } + } + return out; + } + + /** Parses a single {@code via-parm} (no commas). */ + public static ViaHeader parseSingle(String text) { + int sp = indexOfWs(text, 0); + if (sp < 0) { + throw malformed("Via missing whitespace between sent-protocol and sent-by: '" + + text + "'"); + } + String sentProtocol = text.substring(0, sp); + String[] protoParts = splitChar(sentProtocol, '/'); + if (protoParts.length != 3) { + throw malformed("Via sent-protocol must have exactly two '/' separators: '" + + sentProtocol + "'"); + } + String protocolName = protoParts[0].trim(); + String protocolVersion = protoParts[1].trim(); + String transport = protoParts[2].trim(); + if (protocolName.isEmpty() || protocolVersion.isEmpty() || transport.isEmpty()) { + throw malformed("Via sent-protocol has empty component: '" + sentProtocol + "'"); + } + + int i = sp; + while (i < text.length() && isWs(text.charAt(i))) { + i++; + } + + int semi = indexOf(text, ';', i); + String sentByText = (semi < 0 ? text.substring(i) : text.substring(i, semi)).trim(); + HostPort sentBy = parseSentBy(sentByText); + + Map params = new LinkedHashMap<>(); + if (semi >= 0) { + for (String p : splitParams(text.substring(semi + 1))) { + int eq = p.indexOf('='); + if (eq < 0) { + String name = p.trim(); + if (name.isEmpty()) { + throw malformed( + "Via parameter list contains an empty token " + + "(extraneous ';' separators)"); + } + params.put(name, ""); + } else { + String name = p.substring(0, eq).trim(); + String value = p.substring(eq + 1).trim(); + if (name.isEmpty()) { + throw malformed("Via parameter has empty name in '" + p + "'"); + } + if (value.isEmpty()) { + throw malformed( + "Via parameter has empty value (separator without value): '" + + p + "'"); + } + params.put(name, value); + } + } + } + return new ViaHeader(protocolName, protocolVersion, transport, sentBy, params); + } + + private static HostPort parseSentBy(String text) { + if (text.isEmpty()) { + throw malformed("Via sent-by is empty"); + } + if (text.startsWith("[")) { + int closeBracket = text.indexOf(']'); + if (closeBracket < 0) { + throw malformed("Via sent-by missing closing ']' for IPv6 reference"); + } + String host = text.substring(0, closeBracket + 1); + if (closeBracket + 1 == text.length()) { + return HostPort.of(host); + } + if (text.charAt(closeBracket + 1) != ':') { + throw malformed("Via sent-by garbage after IPv6 reference: '" + text + "'"); + } + return new HostPort(host, parsePort(text.substring(closeBracket + 2))); + } + int colon = text.lastIndexOf(':'); + if (colon < 0) { + return HostPort.of(text); + } + return new HostPort(text.substring(0, colon), parsePort(text.substring(colon + 1))); + } + + private static int parsePort(String s) { + try { + int p = Integer.parseInt(s.trim()); + if (p < 0 || p > 65535) { + throw malformed("Via sent-by port out of range: " + p); + } + return p; + } catch (NumberFormatException e) { + throw malformed("Via sent-by port is not numeric: '" + s + "'", e); + } + } + + /** Splits header values on commas at top level (i.e. not inside quoted strings). */ + static List splitTopLevelCommas(String s) { + List out = new ArrayList<>(); + boolean inQuotes = false; + int start = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' && (i == 0 || s.charAt(i - 1) != '\\')) { + inQuotes = !inQuotes; + } else if (c == ',' && !inQuotes) { + out.add(s.substring(start, i)); + start = i + 1; + } + } + out.add(s.substring(start)); + return out; + } + + /** Splits a parameter list (text after the first ';') on top-level ';'s. */ + static List splitParams(String s) { + List out = new ArrayList<>(); + boolean inQuotes = false; + int start = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' && (i == 0 || s.charAt(i - 1) != '\\')) { + inQuotes = !inQuotes; + } else if (c == ';' && !inQuotes) { + out.add(s.substring(start, i)); + start = i + 1; + } + } + out.add(s.substring(start)); + return out; + } + + private static int indexOf(String s, char c, int from) { + return s.indexOf(c, from); + } + + private static int indexOfWs(String s, int from) { + for (int i = from; i < s.length(); i++) { + if (isWs(s.charAt(i))) return i; + } + return -1; + } + + private static boolean isWs(char c) { + return c == ' ' || c == '\t'; + } + + private static String[] splitChar(String s, char c) { + List out = new ArrayList<>(); + int start = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == c) { + out.add(s.substring(start, i)); + start = i + 1; + } + } + out.add(s.substring(start)); + return out.toArray(new String[0]); + } + + private static SipCodecException malformed(String message) { + return new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, message); + } + + private static SipCodecException malformed(String message, Throwable cause) { + return new SipCodecException( + SipCodecException.Category.MALFORMED_HEADER, -1, message, cause); + } +} diff --git a/sip-codec/src/main/java/module-info.java b/sip-codec/src/main/java/module-info.java index 2a29c90..1ed21ca 100644 --- a/sip-codec/src/main/java/module-info.java +++ b/sip-codec/src/main/java/module-info.java @@ -11,4 +11,5 @@ requires transitive com.sip.message; exports com.sip.codec; + exports com.sip.codec.typed; } diff --git a/sip-codec/src/test/java/com/sip/codec/typed/CSeqParserTest.java b/sip-codec/src/test/java/com/sip/codec/typed/CSeqParserTest.java new file mode 100644 index 0000000..6682a09 --- /dev/null +++ b/sip-codec/src/test/java/com/sip/codec/typed/CSeqParserTest.java @@ -0,0 +1,57 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.SipMethod; +import com.sip.message.header.typed.CSeqHeader; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CSeqParserTest { + + @Test + void parsesBasicCSeq() { + CSeqHeader c = CSeqParser.parse("314159 INVITE"); + assertThat(c.sequence()).isEqualTo(314159L); + assertThat(c.method()).isEqualTo(SipMethod.INVITE); + } + + @Test + void parsesLeadingZeros() { + // RFC 4475 §3.1.1.1 wsinv exercises "cseq: 0009 INVITE" + CSeqHeader c = CSeqParser.parse("0009 INVITE"); + assertThat(c.sequence()).isEqualTo(9L); + } + + @Test + void rejectsOutOfRangeSequence() { + assertThatThrownBy(() -> CSeqParser.parse("9999999999 INVITE")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void rejectsNegativeSequence() { + assertThatThrownBy(() -> CSeqParser.parse("-1 INVITE")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void rejectsMissingMethod() { + assertThatThrownBy(() -> CSeqParser.parse("42")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void rejectsInvalidMethodToken() { + assertThatThrownBy(() -> CSeqParser.parse("42 BAD METHOD")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void asWireRoundtrips() { + CSeqHeader c = CSeqParser.parse("123 REGISTER"); + assertThat(c.asWire()).isEqualTo("123 REGISTER"); + assertThat(CSeqParser.parse(c.asWire())).isEqualTo(c); + } +} diff --git a/sip-codec/src/test/java/com/sip/codec/typed/ContactParserTest.java b/sip-codec/src/test/java/com/sip/codec/typed/ContactParserTest.java new file mode 100644 index 0000000..ce2a2bb --- /dev/null +++ b/sip-codec/src/test/java/com/sip/codec/typed/ContactParserTest.java @@ -0,0 +1,60 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.header.Headers; +import com.sip.message.header.typed.ContactValue; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContactParserTest { + + @Test + void parsesSingleContact() { + Headers h = Headers.builder() + .add("Contact", ";expires=3600") + .build(); + List list = ContactParser.parseAll(h); + assertThat(list).hasSize(1); + ContactValue c = list.get(0); + assertThat(c.isWildcard()).isFalse(); + assertThat(c.expires()).contains(3600); + } + + @Test + void parsesWildcard() { + Headers h = Headers.builder() + .add("Contact", "*") + .build(); + List list = ContactParser.parseAll(h); + assertThat(list).hasSize(1); + assertThat(list.get(0).isWildcard()).isTrue(); + } + + @Test + void parsesMultipleContactsInOneHeader() { + Headers h = Headers.builder() + .add("Contact", + ";q=0.5, ;q=1.0") + .build(); + List list = ContactParser.parseAll(h); + assertThat(list).hasSize(2); + assertThat(list.get(0).qValue()).contains(0.5); + assertThat(list.get(1).qValue()).contains(1.0); + } + + @Test + void rejectsWildcardWithOtherContact() { + Headers h = Headers.builder() + .add("Contact", "") + .add("Contact", "*") + .build(); + assertThatThrownBy(() -> ContactParser.parseAll(h)) + .isInstanceOf(SipCodecException.class) + .satisfies(t -> assertThat(((SipCodecException) t).category()) + .isEqualTo(SipCodecException.Category.MALFORMED_HEADER)); + } +} diff --git a/sip-codec/src/test/java/com/sip/codec/typed/NameAddrParserTest.java b/sip-codec/src/test/java/com/sip/codec/typed/NameAddrParserTest.java new file mode 100644 index 0000000..ef9cbe7 --- /dev/null +++ b/sip-codec/src/test/java/com/sip/codec/typed/NameAddrParserTest.java @@ -0,0 +1,117 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.header.typed.NameAddr; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NameAddrParserTest { + + @Test + void parsesBareAddrSpec() { + NameAddr a = NameAddrParser.parseSingle("sip:alice@atlanta.example.com"); + assertThat(a.displayName()).isEmpty(); + assertThat(a.uri().asWire()).isEqualTo("sip:alice@atlanta.example.com"); + assertThat(a.params()).isEmpty(); + } + + @Test + void parsesBareAddrSpecWithTag() { + NameAddr a = NameAddrParser.parseSingle("sip:alice@atlanta.example.com;tag=1928301774"); + assertThat(a.displayName()).isEmpty(); + assertThat(a.tag()).contains("1928301774"); + } + + @Test + void parsesAngleBracketedAddrSpec() { + NameAddr a = NameAddrParser.parseSingle(";tag=a6c85cf"); + assertThat(a.displayName()).isEmpty(); + assertThat(a.uri().scheme()).isEqualTo("sip"); + assertThat(a.tag()).contains("a6c85cf"); + } + + @Test + void parsesTokenDisplayName() { + NameAddr a = NameAddrParser.parseSingle("Alice ;tag=1"); + assertThat(a.displayName()).contains("Alice"); + assertThat(a.tag()).contains("1"); + } + + @Test + void parsesNoLwsBetweenDisplayNameAndAngle() { + // RFC 4475 §3.1.1.6 lwsdisp: "caller;tag=323" + NameAddr a = NameAddrParser.parseSingle("caller;tag=323"); + assertThat(a.displayName()).contains("caller"); + assertThat(a.tag()).contains("323"); + } + + @Test + void parsesQuotedDisplayName() { + NameAddr a = NameAddrParser.parseSingle( + "\"J Doe\" ;tag=t1"); + assertThat(a.displayName()).contains("J Doe"); + assertThat(a.tag()).contains("t1"); + } + + @Test + void parsesQuotedDisplayNameWithEscapes() { + NameAddr a = NameAddrParser.parseSingle( + "\"J Rosenberg \\\\\\\"\" "); + // Quoted "J Rosenberg \\\"" → unescaped to: J Rosenberg \" + assertThat(a.displayName()).contains("J Rosenberg \\\""); + } + + @Test + void parsesListOfNameAddrs() { + List list = NameAddrParser.parseList( + ", Bob , \"Carol\" ;tag=t"); + assertThat(list).hasSize(3); + assertThat(list.get(1).displayName()).contains("Bob"); + assertThat(list.get(2).tag()).contains("t"); + } + + @Test + void rejectsMissingClosingAngle() { + assertThatThrownBy(() -> NameAddrParser.parseSingle("Alice assertThat(((SipCodecException) t).category()) + .isEqualTo(SipCodecException.Category.MALFORMED_HEADER)); + } + + @Test + void rejectsUnterminatedQuotedString() { + assertThatThrownBy(() -> NameAddrParser.parseSingle("\"never closed ")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void rejectsBadinv01StyleContact() { + // RFC 4475 §3.1.2.1 Contact: "\"Joe\" ;;;;" + assertThatThrownBy(() -> NameAddrParser.parseSingle( + "\"Joe\" ;;;;")) + .isInstanceOf(SipCodecException.class) + .satisfies(t -> assertThat(((SipCodecException) t).category()) + .isEqualTo(SipCodecException.Category.MALFORMED_HEADER)); + } + + @Test + void asWireRoundtrips() { + String[] cases = { + "sip:alice@atlanta", + ";tag=t", + "Alice ;tag=1", + "\"J Doe\" ;tag=u" + }; + for (String in : cases) { + NameAddr parsed = NameAddrParser.parseSingle(in); + String back = parsed.asWire(); + assertThat(NameAddrParser.parseSingle(back).asWire()) + .as("roundtrip: " + in) + .isEqualTo(back); + } + } +} diff --git a/sip-codec/src/test/java/com/sip/codec/typed/SimpleHeadersTest.java b/sip-codec/src/test/java/com/sip/codec/typed/SimpleHeadersTest.java new file mode 100644 index 0000000..2cbd9f2 --- /dev/null +++ b/sip-codec/src/test/java/com/sip/codec/typed/SimpleHeadersTest.java @@ -0,0 +1,60 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.header.Headers; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SimpleHeadersTest { + + @Test + void parsesMaxForwards() { + Headers h = Headers.builder().add("Max-Forwards", "70").build(); + assertThat(IntegerHeaderParser.maxForwards(h)).contains(70); + } + + @Test + void rejectsMaxForwardsAboveRange() { + Headers h = Headers.builder().add("Max-Forwards", "300").build(); + assertThatThrownBy(() -> IntegerHeaderParser.maxForwards(h)) + .isInstanceOf(SipCodecException.class); + } + + @Test + void rejectsMaxForwardsNegative() { + Headers h = Headers.builder().add("Max-Forwards", "-1").build(); + assertThatThrownBy(() -> IntegerHeaderParser.maxForwards(h)) + .isInstanceOf(SipCodecException.class); + } + + @Test + void parsesExpires() { + Headers h = Headers.builder().add("Expires", "3600").build(); + assertThat(IntegerHeaderParser.expires(h)).contains(3600); + } + + @Test + void parsesContentLength() { + Headers h = Headers.builder().add("Content-Length", "162").build(); + assertThat(IntegerHeaderParser.contentLength(h)).contains(162); + } + + @Test + void validatesCallId() { + Headers h = Headers.builder() + .add("Call-ID", "a84b4c76e66710@pc33.atlanta") + .build(); + assertThat(CallIdValidator.get(h)).contains("a84b4c76e66710@pc33.atlanta"); + } + + @Test + void rejectsCallIdWithSpace() { + Headers h = Headers.builder() + .add("Call-ID", "bad call id") + .build(); + assertThatThrownBy(() -> CallIdValidator.get(h)) + .isInstanceOf(SipCodecException.class); + } +} diff --git a/sip-codec/src/test/java/com/sip/codec/typed/ViaParserTest.java b/sip-codec/src/test/java/com/sip/codec/typed/ViaParserTest.java new file mode 100644 index 0000000..49703f8 --- /dev/null +++ b/sip-codec/src/test/java/com/sip/codec/typed/ViaParserTest.java @@ -0,0 +1,114 @@ +package com.sip.codec.typed; + +import com.sip.codec.SipCodecException; +import com.sip.message.header.Headers; +import com.sip.message.header.typed.ViaHeader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ViaParserTest { + + @Test + void parsesSingleViaHeader() { + ViaHeader v = ViaParser.parseSingle( + "SIP/2.0/UDP pc33.atlanta.example.com;branch=z9hG4bKnashds10"); + assertThat(v.protocolName()).isEqualTo("SIP"); + assertThat(v.protocolVersion()).isEqualTo("2.0"); + assertThat(v.transport()).isEqualTo("UDP"); + assertThat(v.sentBy().host()).isEqualTo("pc33.atlanta.example.com"); + assertThat(v.sentBy().hasPort()).isFalse(); + assertThat(v.branch()).contains("z9hG4bKnashds10"); + } + + @Test + void parsesIpv6SentBy() { + ViaHeader v = ViaParser.parseSingle( + "SIP/2.0/UDP [2001:db8::1]:5060;branch=z9hG4bK1"); + assertThat(v.sentBy().host()).isEqualTo("[2001:db8::1]"); + assertThat(v.sentBy().port()).isEqualTo(5060); + } + + @Test + void parsesMultipleViaValuesInSingleHeader() { + Headers h = Headers.builder() + .add("Via", "SIP/2.0/UDP h1;branch=b1, SIP/2.0/TCP h2;branch=b2") + .build(); + List all = ViaParser.parseAll(h); + assertThat(all).hasSize(2); + assertThat(all.get(0).transport()).isEqualTo("UDP"); + assertThat(all.get(0).branch()).contains("b1"); + assertThat(all.get(1).transport()).isEqualTo("TCP"); + assertThat(all.get(1).branch()).contains("b2"); + } + + @Test + void parsesAllAcrossMultipleViaHeaders() { + Headers h = Headers.builder() + .add("Via", "SIP/2.0/UDP h1;branch=b1") + .add("Via", "SIP/2.0/TLS h2;branch=b2") + .add("Via", "SIP/2.0/SCTP h3;branch=b3") + .build(); + List all = ViaParser.parseAll(h); + assertThat(all).hasSize(3); + assertThat(all).extracting(ViaHeader::transport) + .containsExactly("UDP", "TLS", "SCTP"); + } + + @Test + void parsesReceivedAndRport() { + ViaHeader v = ViaParser.parseSingle( + "SIP/2.0/UDP host;branch=z9hG4bK1;received=192.0.2.5;rport=5060"); + assertThat(v.received()).contains("192.0.2.5"); + assertThat(v.rport()).contains("5060"); + } + + @Test + void parsesValuelessRport() { + ViaHeader v = ViaParser.parseSingle("SIP/2.0/UDP host;branch=z;rport"); + assertThat(v.rport()).contains(""); + } + + @Test + void rejectsBadinv01StyleEmptySeparators() { + // RFC 4475 §3.1.2.1 Via: "SIP/2.0/UDP 192.0.2.15;;,;,," + // The trailing `;;,;,,` produces empty parameter slots — must be rejected. + assertThatThrownBy(() -> ViaParser.parseAll(Headers.builder() + .add("Via", "SIP/2.0/UDP 192.0.2.15;;,;,,") + .build())) + .isInstanceOf(SipCodecException.class) + .satisfies(t -> assertThat(((SipCodecException) t).category()) + .isEqualTo(SipCodecException.Category.MALFORMED_HEADER)); + } + + @Test + void rejectsMissingTransport() { + assertThatThrownBy(() -> ViaParser.parseSingle("SIP/2.0 host")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void rejectsMissingSentBy() { + assertThatThrownBy(() -> ViaParser.parseSingle("SIP/2.0/UDP")) + .isInstanceOf(SipCodecException.class); + } + + @Test + void asWireRoundtrips() { + String[] cases = { + "SIP/2.0/UDP host.example.com;branch=z9hG4bK1", + "SIP/2.0/TCP host;branch=b;received=192.0.2.1;rport=5060", + "SIP/2.0/UDP [2001:db8::1]:5060;branch=z" + }; + for (String in : cases) { + ViaHeader parsed = ViaParser.parseSingle(in); + String back = parsed.asWire(); + assertThat(ViaParser.parseSingle(back).asWire()) + .as("roundtrip: " + in) + .isEqualTo(back); + } + } +} 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 index 828085c..7d87c94 100644 --- 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 @@ -3,6 +3,8 @@ import com.sip.codec.SipCodecException; import com.sip.codec.SipEncoder; import com.sip.codec.SipParser; +import com.sip.codec.typed.ContactParser; +import com.sip.codec.typed.ViaParser; import com.sip.compliance.fixture.FixtureExpectation; import com.sip.compliance.fixture.FixtureRepository; import com.sip.compliance.fixture.MessageKind; @@ -59,8 +61,7 @@ Iterable everyAcceptFixtureRoundtripsThroughEncoder() { /** * Reject-conformance is scoped to the {@code structural} parser phase. * Fixtures whose rejection happens later in the pipeline (typed-header - * parsing, transaction layer, …) live in the repository but are filtered - * out here and become the gate for whichever phase ships them. + * parsing, transaction layer, …) are tested separately below. */ @TestFactory Iterable everyStructuralRejectFixtureIsRejectedWithDeclaredCategory() { @@ -72,6 +73,24 @@ Iterable everyStructuralRejectFixtureIsRejectedWithDeclaredCategory .toList(); } + /** + * Typed-header phase rejections: the structural parser is tolerant + * but a typed Via / Contact / From / To parser must reject. We run + * each fixture through the structural parser then through the typed + * Via and Contact parsers; the test passes when at least one of the + * typed parsers throws a {@link SipCodecException} matching the + * declared category. + */ + @TestFactory + Iterable everyTypedRejectFixtureIsRejectedByATypedHeaderParser() { + return FIXTURES.stream() + .filter(fx -> fx.expectation() instanceof FixtureExpectation.Reject r + && "typed-header".equals(r.phase())) + .map(fx -> dynamicTest("typed-reject: " + fx.id(), + () -> assertTypedHeaderParserRejects(fx))) + .toList(); + } + private static void assertParserAccepts(TortureFixture fx) { FixtureExpectation.Accept e = (FixtureExpectation.Accept) fx.expectation(); SipMessage msg = SipParser.parse(fx.bytes()); @@ -152,4 +171,22 @@ private static void assertParserRejects(TortureFixture fx) { .as(fx.id() + ": category mismatch") .isEqualTo(r.category()); } + + private static void assertTypedHeaderParserRejects(TortureFixture fx) { + FixtureExpectation.Reject r = (FixtureExpectation.Reject) fx.expectation(); + SipMessage msg = SipParser.parse(fx.bytes()); + + SipCodecException viaError = catchThrowableOfType( + SipCodecException.class, () -> ViaParser.parseAll(msg.headers())); + SipCodecException contactError = catchThrowableOfType( + SipCodecException.class, () -> ContactParser.parseAll(msg.headers())); + + SipCodecException either = viaError != null ? viaError : contactError; + assertThat(either) + .as(fx.id() + ": at least one typed-header parser must reject") + .isNotNull(); + assertThat(either.category()) + .as(fx.id() + ": typed-header rejection category mismatch") + .isEqualTo(r.category()); + } } diff --git a/sip-message/src/main/java/com/sip/message/header/typed/CSeqHeader.java b/sip-message/src/main/java/com/sip/message/header/typed/CSeqHeader.java new file mode 100644 index 0000000..e04eafa --- /dev/null +++ b/sip-message/src/main/java/com/sip/message/header/typed/CSeqHeader.java @@ -0,0 +1,33 @@ +package com.sip.message.header.typed; + +import com.sip.message.SipMethod; + +import java.util.Objects; + +/** + * Typed view of a CSeq header (RFC 3261 §20.16, §25 ABNF). + * + *
+ *   CSeq  =  "CSeq" HCOLON 1*DIGIT LWS Method
+ * 
+ * + * @param sequence non-negative sequence number; must fit in 32 bits (per RFC) + * @param method the request method this CSeq tracks + */ +public record CSeqHeader(long sequence, SipMethod method) { + + /** RFC 3261 §8.1.1.5: sequence numbers MUST be less than 2^31. */ + public static final long MAX_SEQUENCE = (1L << 31) - 1; + + public CSeqHeader { + Objects.requireNonNull(method, "method"); + if (sequence < 0 || sequence > MAX_SEQUENCE) { + throw new IllegalArgumentException( + "CSeq sequence out of range [0," + MAX_SEQUENCE + "]: " + sequence); + } + } + + public String asWire() { + return sequence + " " + method.name(); + } +} diff --git a/sip-message/src/main/java/com/sip/message/header/typed/ContactValue.java b/sip-message/src/main/java/com/sip/message/header/typed/ContactValue.java new file mode 100644 index 0000000..1effe96 --- /dev/null +++ b/sip-message/src/main/java/com/sip/message/header/typed/ContactValue.java @@ -0,0 +1,73 @@ +package com.sip.message.header.typed; + +import java.util.Objects; +import java.util.Optional; + +/** + * Contact header field value (RFC 3261 §20.10). + * + *

A Contact value is either:

+ *
    + *
  • A {@link #wildcard() wildcard} ("*") — only valid in a REGISTER + * intended to remove all bindings;
  • + *
  • A {@code name-addr} with optional parameters + * ({@code expires}, {@code q}, custom).
  • + *
+ */ +public final class ContactValue { + + private static final ContactValue WILDCARD = new ContactValue(null); + + private final NameAddr nameAddr; + + private ContactValue(NameAddr nameAddr) { + this.nameAddr = nameAddr; + } + + public static ContactValue wildcard() { + return WILDCARD; + } + + public static ContactValue of(NameAddr nameAddr) { + return new ContactValue(Objects.requireNonNull(nameAddr, "nameAddr")); + } + + public boolean isWildcard() { + return nameAddr == null; + } + + public Optional nameAddr() { + return Optional.ofNullable(nameAddr); + } + + /** RFC 3261 §20.10 — {@code expires} parameter (seconds), if present. */ + public Optional expires() { + if (nameAddr == null) return Optional.empty(); + return nameAddr.param("expires").map(Integer::parseInt); + } + + /** RFC 3261 §20.10 — {@code q} parameter (preference 0..1), if present. */ + public Optional qValue() { + if (nameAddr == null) return Optional.empty(); + return nameAddr.param("q").map(Double::parseDouble); + } + + public String asWire() { + return isWildcard() ? "*" : nameAddr.asWire(); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof ContactValue other && Objects.equals(nameAddr, other.nameAddr); + } + + @Override + public int hashCode() { + return Objects.hashCode(nameAddr); + } + + @Override + public String toString() { + return asWire(); + } +} diff --git a/sip-message/src/main/java/com/sip/message/header/typed/HostPort.java b/sip-message/src/main/java/com/sip/message/header/typed/HostPort.java new file mode 100644 index 0000000..cb7757b --- /dev/null +++ b/sip-message/src/main/java/com/sip/message/header/typed/HostPort.java @@ -0,0 +1,41 @@ +package com.sip.message.header.typed; + +import java.util.Objects; + +/** + * Host + optional port pair used by Via {@code sent-by} and Contact host + * components (RFC 3261 §25 {@code host:port} ABNF). + * + * @param host hostname, IPv4 address, or bracketed IPv6 reference + * ({@code [2001:db8::1]}) + * @param port {@code -1} when the port is not declared + */ +public record HostPort(String host, int port) { + + public HostPort { + Objects.requireNonNull(host, "host"); + if (host.isEmpty()) { + throw new IllegalArgumentException("host must not be empty"); + } + if (port < -1 || port > 65535) { + throw new IllegalArgumentException("invalid port: " + port); + } + } + + public static HostPort of(String host) { + return new HostPort(host, -1); + } + + public static HostPort of(String host, int port) { + return new HostPort(host, port); + } + + public boolean hasPort() { + return port >= 0; + } + + /** Canonical wire form: {@code host} or {@code host:port}. */ + public String asWire() { + return port >= 0 ? host + ":" + port : host; + } +} diff --git a/sip-message/src/main/java/com/sip/message/header/typed/NameAddr.java b/sip-message/src/main/java/com/sip/message/header/typed/NameAddr.java new file mode 100644 index 0000000..7ae9715 --- /dev/null +++ b/sip-message/src/main/java/com/sip/message/header/typed/NameAddr.java @@ -0,0 +1,116 @@ +package com.sip.message.header.typed; + +import com.sip.message.uri.Uri; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * RFC 3261 §25 {@code name-addr} construction used by From, To, Contact, + * Route and Record-Route header field values. + * + *
+ *   name-addr        =  [ display-name ] LAQUOT addr-spec RAQUOT
+ *   addr-spec        =  SIP-URI / SIPS-URI / absoluteURI
+ *   display-name     =  *(token LWS) / quoted-string
+ * 
+ * + *

This record additionally carries header parameters (everything that + * follows the {@code } or the {@code addr-spec} itself, separated + * by semicolons). Examples include {@code ;tag=xyz} on From / To and + * {@code ;q=0.5;expires=3600} on Contact.

+ * + * @param displayName optional human-readable label (already unquoted) + * @param uri the contained URI (already parsed) + * @param params header parameters in declaration order + */ +public record NameAddr( + Optional displayName, + Uri uri, + Map params) { + + public NameAddr { + Objects.requireNonNull(displayName, "displayName"); + Objects.requireNonNull(uri, "uri"); + Objects.requireNonNull(params, "params"); + params = Collections.unmodifiableMap(new LinkedHashMap<>(params)); + } + + public Optional tag() { + return Optional.ofNullable(params.get("tag")); + } + + public Optional param(String name) { + return Optional.ofNullable(params.get(name)); + } + + /** Canonical wire form matching RFC 3261 §25. */ + public String asWire() { + StringBuilder sb = new StringBuilder(64); + displayName.ifPresent(d -> sb.append(quoteIfNeeded(d)).append(' ')); + sb.append('<').append(uri.asWire()).append('>'); + for (var entry : params.entrySet()) { + sb.append(';').append(entry.getKey()); + if (!entry.getValue().isEmpty()) { + sb.append('=').append(entry.getValue()); + } + } + return sb.toString(); + } + + private static String quoteIfNeeded(String displayName) { + if (displayName.isEmpty()) { + return "\"\""; + } + boolean safe = true; + for (int i = 0; i < displayName.length(); i++) { + char c = displayName.charAt(i); + if (c == ' ' || c == '\t' || c == ',' || c == ';' || c == '<' || c == '>' + || c == '"' || c == '\\' || (c & 0x80) != 0) { + safe = false; + break; + } + } + if (safe) { + return displayName; + } + StringBuilder sb = new StringBuilder(displayName.length() + 2); + sb.append('"'); + for (int i = 0; i < displayName.length(); i++) { + char c = displayName.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\'); + } + sb.append(c); + } + sb.append('"'); + return sb.toString(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String displayName; + private Uri uri; + private final Map params = new LinkedHashMap<>(); + + Builder() { } + + public Builder displayName(String d) { this.displayName = d; return this; } + public Builder uri(Uri u) { this.uri = u; return this; } + public Builder param(String name, String value) { + params.put(name, value == null ? "" : value); + return this; + } + public Builder tag(String t) { return param("tag", t); } + + public NameAddr build() { + return new NameAddr(Optional.ofNullable(displayName), uri, params); + } + } +} diff --git a/sip-message/src/main/java/com/sip/message/header/typed/ViaHeader.java b/sip-message/src/main/java/com/sip/message/header/typed/ViaHeader.java new file mode 100644 index 0000000..afce964 --- /dev/null +++ b/sip-message/src/main/java/com/sip/message/header/typed/ViaHeader.java @@ -0,0 +1,120 @@ +package com.sip.message.header.typed; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Typed view of a single Via header field value (RFC 3261 §20.42, §25 ABNF). + * + *
+ *   Via             =  ( "Via" / "v" ) HCOLON via-parm *(COMMA via-parm)
+ *   via-parm        =  sent-protocol LWS sent-by *( SEMI via-params )
+ *   sent-protocol   =  protocol-name SLASH protocol-version
+ *                       SLASH transport
+ *   sent-by         =  host [ COLON port ]
+ *   via-params      =  via-ttl / via-maddr / via-received
+ *                    / via-branch / via-extension
+ * 
+ * + *

A single Via header field MAY carry multiple {@code via-parm} values + * separated by commas; each is represented by one {@link ViaHeader} + * instance.

+ * + * @param protocolName typically {@code "SIP"} + * @param protocolVersion typically {@code "2.0"} + * @param transport typically {@code "UDP"} / {@code "TCP"} / {@code "TLS"} + * / {@code "SCTP"} / {@code "WS"} / {@code "WSS"} + * — uppercase by convention + * @param sentBy host[:port] of the originator + * @param params via-params (branch, received, rport, ttl, maddr, …) + * preserving insertion order + */ +public record ViaHeader( + String protocolName, + String protocolVersion, + String transport, + HostPort sentBy, + Map params) { + + public ViaHeader { + Objects.requireNonNull(protocolName, "protocolName"); + Objects.requireNonNull(protocolVersion, "protocolVersion"); + Objects.requireNonNull(transport, "transport"); + Objects.requireNonNull(sentBy, "sentBy"); + Objects.requireNonNull(params, "params"); + params = Collections.unmodifiableMap(new LinkedHashMap<>(params)); + } + + /** RFC 3261 §17 — the transaction-identifying parameter. */ + public Optional branch() { + return Optional.ofNullable(params.get("branch")); + } + + /** RFC 3581 — {@code received} parameter (set by next-hop server). */ + public Optional received() { + return Optional.ofNullable(params.get("received")); + } + + /** RFC 3581 — {@code rport} parameter (present, present=value, or absent). */ + public Optional rport() { + return Optional.ofNullable(params.get("rport")); + } + + public Optional ttl() { + return Optional.ofNullable(params.get("ttl")); + } + + public Optional maddr() { + return Optional.ofNullable(params.get("maddr")); + } + + /** Canonical wire form (matches RFC 3261 §25 ABNF). */ + public String asWire() { + StringBuilder sb = new StringBuilder(64); + sb.append(protocolName).append('/').append(protocolVersion).append('/') + .append(transport).append(' ').append(sentBy.asWire()); + for (var entry : params.entrySet()) { + sb.append(';').append(entry.getKey()); + if (!entry.getValue().isEmpty()) { + sb.append('=').append(entry.getValue()); + } + } + return sb.toString(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String protocolName = "SIP"; + private String protocolVersion = "2.0"; + private String transport; + private HostPort sentBy; + private final Map params = new LinkedHashMap<>(); + + Builder() { } + + public Builder protocolName(String v) { this.protocolName = v; return this; } + public Builder protocolVersion(String v) { this.protocolVersion = v; return this; } + public Builder transport(String v) { this.transport = v; return this; } + public Builder sentBy(HostPort v) { this.sentBy = v; return this; } + public Builder sentBy(String host, int port) { + return sentBy(new HostPort(host, port)); + } + public Builder param(String name, String value) { + params.put(name, value == null ? "" : value); + return this; + } + public Builder branch(String b) { return param("branch", b); } + public Builder received(String r) { return param("received", r); } + public Builder rport(String r) { return param("rport", r == null ? "" : r); } + + public ViaHeader build() { + return new ViaHeader(protocolName, protocolVersion, transport, sentBy, params); + } + } +} diff --git a/sip-message/src/main/java/com/sip/message/header/typed/package-info.java b/sip-message/src/main/java/com/sip/message/header/typed/package-info.java new file mode 100644 index 0000000..b9ab663 --- /dev/null +++ b/sip-message/src/main/java/com/sip/message/header/typed/package-info.java @@ -0,0 +1,11 @@ +/** + * Typed views of common SIP header field values. + * + *

These types are immutable records / value objects that wrap parsed + * structures (host:port, name-addr, parameters). They live in + * {@code sip-message} so that callers can construct outbound messages + * without depending on {@code sip-codec}. Actual parsing from + * {@link com.sip.message.header.RawHeader RawHeader} text values lives in + * {@code com.sip.codec.typed} inside the {@code sip-codec} module.

+ */ +package com.sip.message.header.typed; diff --git a/sip-message/src/main/java/com/sip/message/uri/SipUri.java b/sip-message/src/main/java/com/sip/message/uri/SipUri.java index 482509a..2b6201b 100644 --- a/sip-message/src/main/java/com/sip/message/uri/SipUri.java +++ b/sip-message/src/main/java/com/sip/message/uri/SipUri.java @@ -1,5 +1,6 @@ package com.sip.message.uri; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -41,8 +42,8 @@ public record SipUri( if (port < -1 || port > 65535) { throw new IllegalArgumentException("invalid port: " + port); } - params = Map.copyOf(params); - headers = Map.copyOf(headers); + params = Collections.unmodifiableMap(new LinkedHashMap<>(params)); + headers = Collections.unmodifiableMap(new LinkedHashMap<>(headers)); } @Override diff --git a/sip-message/src/main/java/module-info.java b/sip-message/src/main/java/module-info.java index bc93f69..f21e0d5 100644 --- a/sip-message/src/main/java/module-info.java +++ b/sip-message/src/main/java/module-info.java @@ -7,5 +7,6 @@ module com.sip.message { exports com.sip.message; exports com.sip.message.header; + exports com.sip.message.header.typed; exports com.sip.message.uri; }