From 3325be504431680e7b8ede99db7156e4cabe3ca2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 11 May 2026 15:22:02 +0000 Subject: [PATCH] =?UTF-8?q?feat(codec):=20=E5=AE=9E=E8=A3=85=20SipEncoder?= =?UTF-8?q?=20+=20=E5=85=A8=20fixture=20roundtrip=20=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SipEncoder(手写 ByteArrayOutputStream 拼装) - Request-Line / Status-Line:直写 ASCII - Headers:按插入顺序保序输出(保 Via 栈式语义) - 关键不变量:Content-Length 始终由实际 body 长度推导, 即使输入消息携带不一致的 Content-Length 也会被纠正 - Status-Code 用单字符算术输出(避免 String 分配) - header value / reason phrase 走 UTF-8(RFC 3261 §7.3.1) SipEncoderTest(5 个单测) - OPTIONS 请求编码 - 200 OK 响应编码 - Content-Length 与实际 body 长度自动同步 - 缺 Content-Length 时自动注入 - parse→encode→parse 不动语义 ParserConformanceTest 扩展 - 新增 'roundtrip' dynamic-test 系列 - 每个 accept fixture 跑 parse→encode→parse,断言: · body 字节级相等 · start-line(method/URI 或 status/reason)相等 · header count 与 manifest 声明一致 全 6 个 accept fixture 通过 roundtrip: self-test/simple-options, simple-200-ok, invite-with-sdp rfc4475/3.1.1.6-lwsdisp, 3.1.1.9-semiuri, 3.1.1.10-transports mvn verify:9 模块 SUCCESS,33 tests 全绿。 Co-authored-by: li xuanqun <793005378@qq.com> --- .../main/java/com/sip/codec/SipEncoder.java | 112 +++++++++++++++++- .../java/com/sip/codec/SipEncoderTest.java | 111 +++++++++++++++++ .../parser/ParserConformanceTest.java | 46 +++++++ 3 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 sip-codec/src/test/java/com/sip/codec/SipEncoderTest.java 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.

+ * + *

Content-Length handling

+ *

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 ;tag=1\r\n" + + "To: Bob \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()); + } +} 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 34d9753..828085c 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 @@ -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; @@ -41,6 +42,20 @@ Iterable 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 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 @@ -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(