diff --git a/sip-codec/src/main/java/com/sip/codec/SipEncoder.java b/sip-codec/src/main/java/com/sip/codec/SipEncoder.java index 65b2596..1d76a18 100644 --- a/sip-codec/src/main/java/com/sip/codec/SipEncoder.java +++ b/sip-codec/src/main/java/com/sip/codec/SipEncoder.java @@ -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. * - *
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.
+ *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.
+ * + *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.
*/ 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); } } diff --git a/sip-codec/src/test/java/com/sip/codec/SipEncoderTest.java b/sip-codec/src/test/java/com/sip/codec/SipEncoderTest.java new file mode 100644 index 0000000..8be8143 --- /dev/null +++ b/sip-codec/src/test/java/com/sip/codec/SipEncoderTest.java @@ -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