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
112 changes: 107 additions & 5 deletions sip-codec/src/main/java/com/sip/codec/SipEncoder.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,123 @@
package com.sip.codec;

import com.sip.message.SipMessage;
import com.sip.message.SipRequest;
import com.sip.message.SipResponse;
import com.sip.message.header.HeaderName;
import com.sip.message.header.Headers;
import com.sip.message.header.RawHeader;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

/**
* Hand-written SIP wire-format encoder.
*
* <p>The encoder is responsible for producing on-the-wire bytes that
* round-trip through {@link SipParser}, and for honoring
* {@code Content-Length} based on the actual body it serializes.</p>
* <p>Produces bytes that round-trip through {@link SipParser}. The encoder
* is deliberately allocation-light: it builds the output in a single
* pre-grown {@link ByteArrayOutputStream} and writes ASCII bytes directly
* for the syntactic envelope (start-line and header names), reserving
* UTF-8 transcoding for header values and reason phrases.</p>
*
* <h2>Content-Length handling</h2>
* <p>The encoder always emits a {@code Content-Length} header whose value
* matches the actual body length, regardless of any value present in the
* input message. This keeps wire output internally consistent and avoids
* a common class of bugs where producer-side code mutates the body but
* forgets to update the header.</p>
*/
public final class SipEncoder {

private static final byte[] CRLF = {'\r', '\n'};
private static final byte[] SP = {' '};
private static final byte[] COLON_SP = {':', ' '};
private static final byte[] CONTENT_LENGTH_PREFIX =
"Content-Length: ".getBytes(StandardCharsets.US_ASCII);

private SipEncoder() { }

/** Encodes {@code message} into a freshly allocated byte array. */
public static byte[] encode(SipMessage message) {
throw new UnsupportedOperationException(
"SipEncoder is a scaffold; the encoder lands in the next iteration.");
Objects.requireNonNull(message, "message");
ByteArrayOutputStream out = new ByteArrayOutputStream(256);
try {
switch (message) {
case SipRequest r -> writeRequestLine(out, r);
case SipResponse r -> writeStatusLine(out, r);
}
writeHeaders(out, message.headers(), message.body().length);
out.write(CRLF);
byte[] body = message.body();
if (body.length > 0) {
out.write(body);
}
} catch (IOException e) {
// ByteArrayOutputStream never throws — but the type signature requires this.
throw new SipCodecException(
SipCodecException.Category.ENCODE_FAILURE, -1,
"I/O while encoding message (unexpected for in-memory buffer)", e);
}
return out.toByteArray();
}

private static void writeRequestLine(ByteArrayOutputStream out, SipRequest r)
throws IOException {
out.write(asciiBytes(r.method().name()));
out.write(SP);
out.write(asciiBytes(r.requestUri().asWire()));
out.write(SP);
out.write(asciiBytes(r.version().literal()));
out.write(CRLF);
}

private static void writeStatusLine(ByteArrayOutputStream out, SipResponse r)
throws IOException {
out.write(asciiBytes(r.version().literal()));
out.write(SP);
// Status-Code is always 3 ASCII digits.
int s = r.status();
out.write((s / 100) + '0');
out.write(((s / 10) % 10) + '0');
out.write((s % 10) + '0');
out.write(SP);
out.write(r.reason().getBytes(StandardCharsets.UTF_8));
out.write(CRLF);
}

private static void writeHeaders(ByteArrayOutputStream out, Headers headers,
int bodyLength) throws IOException {
boolean contentLengthWritten = false;
for (RawHeader h : headers.asList()) {
if (h.name().equals(HeaderName.CONTENT_LENGTH)) {
writeContentLength(out, bodyLength);
contentLengthWritten = true;
continue;
}
writeHeader(out, h);
}
if (!contentLengthWritten) {
writeContentLength(out, bodyLength);
}
}

private static void writeHeader(ByteArrayOutputStream out, RawHeader h)
throws IOException {
out.write(asciiBytes(h.name().canonical()));
out.write(COLON_SP);
out.write(h.value().getBytes(StandardCharsets.UTF_8));
out.write(CRLF);
}

private static void writeContentLength(ByteArrayOutputStream out, int bodyLength)
throws IOException {
out.write(CONTENT_LENGTH_PREFIX);
out.write(Integer.toString(bodyLength).getBytes(StandardCharsets.US_ASCII));
out.write(CRLF);
}

private static byte[] asciiBytes(String s) {
return s.getBytes(StandardCharsets.US_ASCII);
}
}
111 changes: 111 additions & 0 deletions sip-codec/src/test/java/com/sip/codec/SipEncoderTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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.Headers;
import com.sip.message.uri.OpaqueUri;
import org.junit.jupiter.api.Test;

import java.nio.charset.StandardCharsets;

import static org.assertj.core.api.Assertions.assertThat;

class SipEncoderTest {

@Test
void encodesMinimalOptionsRequest() {
SipRequest req = new SipRequest(
SipMethod.OPTIONS,
new OpaqueUri("sip", "carol@example.com"),
SipVersion.SIP_2_0,
Headers.builder()
.add("Via", "SIP/2.0/UDP host;branch=z9hG4bK1")
.add("Max-Forwards", "70")
.build(),
new byte[0]);

byte[] bytes = SipEncoder.encode(req);
String text = new String(bytes, StandardCharsets.US_ASCII);

assertThat(text).isEqualTo(
"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");
}

@Test
void encodesResponseWith3DigitStatus() {
SipResponse rsp = new SipResponse(
SipVersion.SIP_2_0, 200, "OK",
Headers.empty(),
new byte[0]);

String text = new String(SipEncoder.encode(rsp), StandardCharsets.UTF_8);

assertThat(text).startsWith("SIP/2.0 200 OK\r\n");
assertThat(text).endsWith("Content-Length: 0\r\n\r\n");
}

@Test
void contentLengthIsAlwaysDerivedFromActualBody() {
// Even though the message carries Content-Length: 999, the encoder must
// emit the real body length to keep the wire output self-consistent.
byte[] body = "hello".getBytes(StandardCharsets.UTF_8);
SipRequest req = new SipRequest(
SipMethod.MESSAGE,
new OpaqueUri("sip", "u@e.com"),
SipVersion.SIP_2_0,
Headers.builder()
.add("Content-Type", "text/plain")
.add("Content-Length", "999")
.build(),
body);

String text = new String(SipEncoder.encode(req), StandardCharsets.UTF_8);
assertThat(text)
.contains("Content-Length: 5\r\n")
.doesNotContain("Content-Length: 999")
.endsWith("\r\n\r\nhello");
}

@Test
void encoderInjectsContentLengthEvenWhenAbsent() {
SipRequest req = new SipRequest(
SipMethod.OPTIONS,
new OpaqueUri("sip", "u@e.com"),
SipVersion.SIP_2_0,
Headers.empty(),
new byte[0]);

String text = new String(SipEncoder.encode(req), StandardCharsets.US_ASCII);
assertThat(text).contains("Content-Length: 0");
}

@Test
void roundtripPreservesParsedMessage() {
String wire =
"INVITE sip:bob@biloxi.example.com SIP/2.0\r\n"
+ "Via: SIP/2.0/UDP pc33.atlanta;branch=z9hG4bK1\r\n"
+ "From: Alice <sip:alice@atlanta>;tag=1\r\n"
+ "To: Bob <sip:bob@biloxi>\r\n"
+ "Call-ID: a84b4c76e66710\r\n"
+ "CSeq: 314159 INVITE\r\n"
+ "Max-Forwards: 70\r\n"
+ "Content-Length: 0\r\n"
+ "\r\n";

SipMessage first = SipParser.parse(wire);
byte[] encoded = SipEncoder.encode(first);
SipMessage second = SipParser.parse(encoded);

assertThat(((SipRequest) second).method()).isEqualTo(SipMethod.INVITE);
assertThat(((SipRequest) second).headers().size())
.isEqualTo(((SipRequest) first).headers().size());
assertThat(second.body()).isEqualTo(first.body());
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.sip.compliance.parser;

import com.sip.codec.SipCodecException;
import com.sip.codec.SipEncoder;
import com.sip.codec.SipParser;
import com.sip.compliance.fixture.FixtureExpectation;
import com.sip.compliance.fixture.FixtureRepository;
Expand Down Expand Up @@ -41,6 +42,20 @@ Iterable<DynamicTest> everyAcceptFixtureIsParsed() {
.toList();
}

/**
* Every accept fixture survives a parse → encode → parse round-trip
* preserving start-line, every header (modulo Content-Length
* normalisation), and body.
*/
@TestFactory
Iterable<DynamicTest> everyAcceptFixtureRoundtripsThroughEncoder() {
return FIXTURES.stream()
.filter(fx -> fx.expectation() instanceof FixtureExpectation.Accept)
.map(fx -> dynamicTest("roundtrip: " + fx.id(),
() -> assertRoundtripsCleanly(fx)))
.toList();
}

/**
* Reject-conformance is scoped to the {@code structural} parser phase.
* Fixtures whose rejection happens later in the pipeline (typed-header
Expand Down Expand Up @@ -95,6 +110,37 @@ private static void assertParserAccepts(TortureFixture fx) {
.isEqualTo(e.headerCount());
}

private static void assertRoundtripsCleanly(TortureFixture fx) {
SipMessage first = SipParser.parse(fx.bytes());
byte[] encoded = SipEncoder.encode(first);
SipMessage second = SipParser.parse(encoded);

FixtureExpectation.Accept e = (FixtureExpectation.Accept) fx.expectation();

// Body must survive round-trip byte-for-byte.
assertThat(second.body())
.as(fx.id() + ": body changed during roundtrip")
.isEqualTo(first.body());

// Start-line must survive.
if (first instanceof SipRequest reqA && second instanceof SipRequest reqB) {
assertThat(reqB.method().name()).isEqualTo(reqA.method().name());
assertThat(reqB.requestUri().asWire()).isEqualTo(reqA.requestUri().asWire());
} else if (first instanceof SipResponse rspA && second instanceof SipResponse rspB) {
assertThat(rspB.status()).isEqualTo(rspA.status());
assertThat(rspB.reason()).isEqualTo(rspA.reason());
} else {
throw new AssertionError(fx.id() + ": message kind changed during roundtrip");
}

// Header count must match the manifest (encoder may inject Content-Length
// when the original message lacked it; the manifest already declares the
// post-canonicalisation count, so equality is the right invariant).
assertThat(second.headers().size())
.as(fx.id() + ": header count changed during roundtrip")
.isEqualTo(e.headerCount());
}

private static void assertParserRejects(TortureFixture fx) {
FixtureExpectation.Reject r = (FixtureExpectation.Reject) fx.expectation();
SipCodecException thrown = catchThrowableOfType(
Expand Down
Loading