sipkit

    Sans-IO SIP message parsing, canonical re-emission and conformance diagnostics for MoonBit, validated against the IETF RFC 4475 torture-test corpus.

    sip
    rfc3261
    rfc4475
    parser
    protocol
    voip
    diagnostics
    conformance
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    6 days ago
    Downloads
    13

    No dependencies

    #sipkit

    CI

    Sans-IO SIP message parsing, canonical re-emission and conformance diagnostics for MoonBit.

    SIP is the signalling protocol behind every VoIP call, SBC and IMS core. A SIP parser is also one of the few parsers in a network stack that is routinely fed hostile input: header folding, duplicate Content-Length, escaped characters that must not be unescaped, and user parts containing the very characters that separate parameters.

    sipkit parses SIP messages (RFC 3261) at the octet level, re-emits them byte-for-byte, and reports structural defects with byte offsets. Correctness is not claimed — it is measured against the IETF's own test corpus.

    RFC 4475 torture corpus 49 messages extracted mechanically from the RFC text 45 verdicts asserted, 4 role-dependent (not asserted) all 45 asserted verdicts match every accepted message round-trips byte-for-byte * Robustness >4,000 generated inputs (random, truncated, mutated, spliced) with zero panics Coverage 50 uncovered lines in the library (about 97% of 1,698 executable lines), measured with `moon coverage analyze` Tests 79, green on wasm · wasm-gc · js · native Dependencies none Panics in the library none — no `unwrap`, `panic` or `abort` outside tests API documentation 112 of 112 public functions carry a doc comment

    * mpart01 is a framing vector, not a round-trip vector — see Known limitations.


    #Why this exists

    MoonBit already has WebRTC media (moonbit-community/rtc) and SDP (coke089/moon-sdp), but no signalling layer. A search of all 3,243 modules published on mooncakes.io and 1,805 MoonBit repositories created in 2026 returns zero packages for SIP itself, for RFC 3261, or for the torture corpus. The layer that decides which session to set up is missing while the layer that describes the session already exists.

    The practical consequence: you cannot receive a SIP INVITE in MoonBit today. sipkit is that missing first step, and only that step.

    #What it does

    CapabilityDetail
    ParseRequest-Line, Status-Line, header block with folding, body framing by Content-Length
    URIsip: / sips: per RFC 3261 §19.1: userinfo, IPv4/IPv6 host, port, parameters, URI headers, single-pass % unescaping
    Addressesname-addr / addr-spec / absoluteURI, quoted and token display names, header parameters, comma-separated lists
    Lexicaltoken, quoted-string with quoted-pair, nested comment with a depth cap, LWS including CRLF folding
    Re-emitto_bytes returns the input octets unchanged for any CRLF-framed message
    Diagnose18 defect codes carrying byte offsets, each citing the torture case it implements
    Compact namesRFC 3261 Table 2 single-letter forms (i, l, v, t, f, m, c, s, k, e)

    #What it deliberately does not do

    This is a message layer library. It is sans-IO: it never opens a socket, never resolves a name, and never owns a timer. Consequently:

    • No transaction or dialog layer. No INVITE retransmission, no ACK matching, no Route set computation. RFC 4475 §3.2.1 is therefore checked as a diagnostic (branch identifier present), not as transaction behaviour.
    • No SDP. Content-Type: application/sdp bodies are returned as opaque octets. coke089/moon-sdp is the natural companion.
    • No authentication. Authorization / WWW-Authenticate are parsed as text; no Digest computation, no credential storage.
    • No TLS/DTLS transport. moonbitstack/moontls owns that layer.
    • No extension support. Require and Proxy-Require with any value are reported as unsupported, because the library implements none.
    • Role-dependent verdicts are not asserted. Where RFC 4475 says "a proxy forwards but an endpoint replies 415", the library refuses to guess the caller's role.

    #Conformance evidence

    The evidence is generated from the RFC text, not hand-written:

    python3 tools/extract_rfc4475.py --rfc rfc4475.txt --out-dir . python3 tools/gen_tests.py moon test

    extract_rfc4475.py implements the three notations RFC 4475 §2 uses to print messages (<allOneLine>, <hex>, <repeat>), keeps messages whole across page breaks, and emits the exact octets. rfc4475_expectations.json holds the verdict for each message, read off the prose of its section and reviewed by hand — the extractor never decides validity.

    That separation is the point. A test suite that derives its expectations from its own implementation proves nothing. Here the octets come from the IETF and the verdicts come from the RFC's prose; the implementation is the only thing under test.

    Two cases are worth calling out because they are commonly implemented backwards:

    • lwsdisp (§3.1.1.6) — From: caller<sip:caller@example.com> has no LWS before <. The grammar forbids it, but the RFC identifies this as a specification bug and requires acceptance. sipkit accepts it.
    • esc02 (§3.1.1.5) — % is not an escape everywhere. C%6Fntact is not Contact, and branch=z9hG4bK209%fzsnel234 is well formed. sipkit compares header names literally and accepts a stray %.

    #Robustness

    A SIP parser's worst failure is not rejecting a valid message — it is panicking on an invalid one, because the panic takes the call path down with it. RFC 4475 proves the grammar on 49 curated messages; it says nothing about the other infinity of inputs.

    fuzz_test.mbt closes that gap with two deterministic generators, so any failure can be replayed on the machine that found it:

    GeneratorInputsWhat it reaches
    Pseudo-random octets1,200Early grammar exits; the error paths
    Structural octets (CR, LF, delimiters, high bits)1,500Adversarial whitespace and delimiter runs
    Truncations of every corpus message~294Partial messages, unterminated constructs
    Single-octet mutations of every corpus message1,176Deep grammar and diagnostics
    Insertions and deletions (including an injected CRLF)~98Framing boundaries

    Result: zero panics, and the invariants hold — every syntax error carries an offset inside the input, and anything that parses can be re-emitted.

    Coverage is measured rather than assumed. moon coverage analyze reports 58 uncovered lines, 50 of them in the library (cmd/main accounts for the rest). Against 1,698 executable lines — counted as non-blank, non-comment lines, the stated denominator so the number can be checked — that is about 97%. The uncovered lines are defensive branches in the lexer that the corpus cannot reach.

    Removing unreachable code is part of this. A Content-Length that is negative or non-numeric is rejected by the grammar layer, so the diagnostics layer used to carry a negative-content-length branch that could never fire; it is gone rather than left as decoration.

    #Quick start

    moon add BenPaoBaMingWangXing/sipkit

    let input : Bytes =
    b"INVITE sip:bob@example.com SIP/2.0\r\nTo: <sip:bob@example.com>\r\nFrom: <sip:alice@example.com>;tag=1\r\nCall-ID: a1\r\nCSeq: 1 INVITE\r\nVia: SIP/2.0/UDP host;branch=z9hG4bK1\r\nMax-Forwards: 70\r\nContent-Length: 0\r\n\r\n"

    let a = analyze(input)
    println(a.verdict()) // well-formed
    let msg = a.message().unwrap()
    println(ascii_of(msg.verb().unwrap().view())) // INVITE
    assert_eq(to_bytes(msg).length(), input.length())

    analyze never raises. A syntax failure is data:

    match analyze(b"garbage").syntax() {
    Some(pair) => println("rejected: \{pair.0} at byte \{pair.1}")
    None => ()
    }

    #Diagnostics

    Each diagnostic carries a code, a severity and a byte offset:

    for d in analyze(input).diagnostics() {
    println("\{d}") // error:content-length-mismatch@214 declared 152 but body is 0
    }

    Severity is not decoration: Verdict follows it directly. well-formed means parsed with no error-severity diagnostic, syntax-rejected means the grammar refused the octets, and semantic-rejected means it parsed but a semantic rule fired.

    Warnings (a missing Max-Forwards, an RFC 2543 style Via without branch, trailing octets) do not change the verdict, because RFC 4475 accepts those messages.

    #Examples

    Three runnable programs, no dependencies, no setup:

    moon run examples/parse_dump # parse an INVITE, print every recovered field moon run examples/diagnose # the three verdicts, side by side moon run examples/uri # URI decomposition, including the awkward cases

    parse_dump prints what actually came out of the octets, not a pretty-printed restatement:

    start line method INVITE uri.scheme sip uri.user bob uri.host example.com uri.port 5060 uri.params 1 ;transport=tcp headers (9), in wire order line 2 Via: SIP/2.0/UDP alice-pc.example.com:5060;branch=z9hG4bK776asdhds ... round-trip identical: true

    diagnose is the one worth reading, because it shows why the two layers are separate — the third case parses cleanly and still fails:

    --- semantic-rejected verdict: semantic-rejected [error] content-length-mismatch at byte 236 declared 42 but body is 5 [error] cseq-method-mismatch at byte 120 CSeq method INVITE != start line OPTIONS --- well-formed, with a warning verdict: well-formed [warning] missing-required-header at byte 0 request has no Max-Forwards header

    A proxy forwards that last message. An implementation that folds warnings into errors drops a call it should have routed.

    uri covers the cases that separate a real parser from a regex:

    --- sip:al%41ice@example.com user al%41ice user* alAice (decoded once) --- sip:al%2541ice@example.com user* al%41ice (decoded once) <- not `alAice`: decoding is single-pass --- sip:user;par=u%40example.net@example.com user user;par=u%40example.net user* user;par=u@example.net <- the ; belongs to the user part

    #Design decisions

    Octets, never String. SIP is an octet protocol; header values may carry octets that are not valid UTF-8. The parser works on Bytes throughout and never materialises a String, so parsing cannot fail or silently rewrite bytes for reasons the wire format does not sanction. (In this toolchain String is UTF-16 — "sip".to_bytes() is six octets, not three — a detail that silently corrupts byte protocols.)

    Two layers, two questions. The grammar layer decides whether octets form a message; the diagnostics layer decides whether a message is acceptable. RFC 4475 splits its corpus along exactly this line (§3.1.2 syntax versus §3.2/§3.3 semantics), and conflating the two is what makes SIP stacks reject messages they should forward.

    trailing is kept, not dropped. Octets after the declared body belong to whatever followed in the datagram. Dropping them hides a real attack surface (request smuggling); parse_message exposes them and to_bytes does not re-emit them.

    Bounded recursion. comment nests in the ABNF, a classic stack-exhaustion vector. The implementation uses an explicit depth counter capped at 16 rather than recursion.

    #Ecosystem position

    Checked against 3,243 mooncakes.io modules and 1,805 MoonBit repositories created in 2026. Neighbouring work, and why sipkit does not overlap:

    ProjectScopeRelationship
    moonbit-community/rtcWebRTC media stackMedia plane; sipkit is the signalling plane
    coke089/moon-sdpSDP (RFC 8866) offer/answerSession description; sipkit keeps bodies opaque
    moonbitstack/moonurlRFC 3986 URIGeneric URI; SIP URI has its own grammar (§19.1)
    moonbitstack/moonhttpHTTP / WebSocketDifferent protocol family

    #Data sources and licensing

    • Source code: Apache-2.0 (see LICENSE).
    • RFC 3261 / RFC 4475 octets reproduced in generated fixtures are used under the IETF Trust Legal Provisions (see NOTICE). No RFC text is bundled: rfc4475.txt is fetched by the reproduction steps and is deliberately absent from the repository.
    • Generated files (rfc4475_test.mbt, rfc4475_vectors.json) contain only message octets and section numbers; provenance is recorded in NOTICE.

    #Reproduce

    Requires MoonBit 0.1.20260904 or newer and Python 3.9+.

    python3 tools/extract_rfc4475.py --rfc rfc4475.txt --out-dir . # needs rfc4475.txt python3 tools/gen_tests.py moon check moon fmt --check moon test --target wasm moon test --target wasm-gc moon test --target js moon coverage analyze # line-level coverage; ~50 uncovered lines in the library

    rfc4475.txt is available from https://www.rfc-editor.org/rfc/rfc4475.txt.

    To reproduce the robustness numbers, just run the tests: the fuzz generators are deterministic and part of the default suite, so moon test either passes or names the seed that fails.

    #Known limitations

    Scope statement first: the final scope of this project is the RFC 3261 message layer (§7 message format, §19.1 SIP-URI, §20 header fields, §25.1 message-layer ABNF). Everything in that scope is implemented except two tracked items with dates (below); everything outside it — dialog layer, transactions, authentication, TLS — is out of scope by design, not "not yet".

    In scope, complete. Typed validation now covers every message-layer header field of §20 — the earlier gaps (Expires, Min-Expires, Warning, In-Reply-To, Rack/RSeq) report malformed-field-value with the field named, and TEXT-UTF8-TRIM fields (Reason-Phrase, Subject, Organization, User-Agent, Server) are checked for well-formed UTF-8 (invalid-utf8: no overlong encodings, no surrogate halves, nothing beyond U+10FFFF). 85 tests, zero warnings.

    Completed after the first application review (previously listed here as limitations):

    • → full §25.1 SIP-date since 2026-10-27: fixed 29-octet shape, weekday/month whitelists, day-in-month with leap years, HH≤23/MM≤59/SS≤60 (60 as leap second), and weekday/date agreement via Zeller's congruence — Mon, 01 Jan 2010 is rejected on the weekday although every token looks plausible.

    Specification-level conflicts, recorded not pending — these have no correct answer in the documents; the implementation takes a defensible side and says so:

    1. : in the user part. RFC 4475 §3.1.1.2's message puts : inside the user part; RFC 3261 §25.1's user-unreserved does not include it (: is the user/password separator). The ABNF wins: the user ends at the first colon, the rest is the password.
    2. Parameter namespace on a bare addr-spec. Contact: sip:a@b;unknownparam without angle brackets makes ;-parameters both URI parameters and contact parameters, and the grammar cannot separate them. RFC 3261 §20.10 admits this and recommends always using <>. The parameter stays reachable and user/host are unaffected.
    3. mpart01 sample data. The RFC prints Content-Length: 553 for a hex body that is 14 octets longer. Framing by Content-Length (the correct behaviour) leaves those 14 octets as trailing.

    Environment, not code: the native backend is verified in CI only — this development machine has no C compiler (cl/cc/gcc/clang all absent). The three JS/Wasm backends pass locally.

    SipError

    pub suberror SipError {
    Syntax(SyntaxKind, Int)
    }

    The only error type the parser raises. offset is a byte offset into the buffer handed to parse_message.
    impl Show for SipError

    SipError::output

    fn SipError::output(self : SipError, logger : &Logger) -> Unit

    SipError::to_string

    fn SipError::to_string(self : SipError) -> String

    AddrSpec

    pub enum AddrSpec {
    Sip(SipUri)
    Other(Bytes)
    }

    addr-spec = SIP-URI / SIPS-URI / absoluteURI (RFC 3261 §25.1). Non-SIP absolute URIs are kept as octets: rejecting them is an application-layer decision, which is how RFC 4475 §3.3.4 ("unksm2") expects isbn: and http: values to survive parsing.
    impl Show for AddrSpec

    AddrSpec::output

    fn AddrSpec::output(self : AddrSpec, logger : &Logger) -> Unit

    AddrSpec::to_string

    fn AddrSpec::to_string(self : AddrSpec) -> String

    Analysis

    pub struct Analysis {
    message : SipMessage?
    syntax : (SyntaxKind, Int)?
    diagnostics : Array[Diagnostic]
    }

    The outcome of analysing one octet string. message is None exactly when the grammar rejected the input, in which case syntax carries the reason and byte offset.

    Analysis::diagnostics

    fn Analysis::diagnostics(self : Analysis) -> Array[Diagnostic]

    Semantic findings. Empty when parsing failed.

    Analysis::has_code

    fn Analysis::has_code(self : Analysis, code : DiagCode) -> Bool

    True when at least one diagnostic carries code. Comparison goes through the stable string form so the enum needs no hand-written Eq instance.

    Analysis::message

    fn Analysis::message(self : Analysis) -> SipMessage?

    The parsed message, or None when the grammar refused the input.

    Analysis::syntax

    fn Analysis::syntax(self : Analysis) -> (SyntaxKind, Int)?

    The syntax error kind and offset, when parsing failed.

    Analysis::verdict

    fn Analysis::verdict(self : Analysis) -> Verdict

    Three-way verdict, mirroring the split RFC 4475 makes.

    Cursor

    pub struct Cursor {
    src : BytesView
    pos : Int
    }

    A read cursor over a byte view. Parsing functions take it by reference semantics (MoonBit structs are references at runtime), so advancing moves the caller's position.

    Cursor::advance

    fn Cursor::advance(self : Cursor) -> Byte?

    Consumes and returns one octet.

    Cursor::at_fold

    fn Cursor::at_fold(self : Cursor) -> Bool

    True when a CRLF at the cursor is followed by at least one SP or HTAB, i.e. the CRLF is a line fold rather than the end of a header field.

    Cursor::consume_fold

    fn Cursor::consume_fold(self : Cursor) -> Bool

    Consumes one line fold (CRLF 1*WSP) if present.

    Cursor::eof

    fn Cursor::eof(self : Cursor) -> Bool

    True when every octet has been consumed.

    Cursor::expect_byte

    fn Cursor::expect_byte(self : Cursor, want : Byte) -> Unit raise SipError

    Consumes one specific octet or raises IllegalOctet at the cursor.

    Cursor::expect_literal

    fn Cursor::expect_literal(self : Cursor, lit : String) -> Unit raise SipError

    Consumes an exact ASCII literal.

    Cursor::from_bytes

    fn Cursor::from_bytes(src : Bytes) -> Cursor

    Creates a cursor over owned octets.

    Cursor::length

    fn Cursor::length(self : Cursor) -> Int

    Total octets in the underlying view.

    Cursor::new

    fn Cursor::new(src : BytesView) -> Cursor

    Creates a cursor over a byte view.

    Cursor::peek

    fn Cursor::peek(self : Cursor) -> Byte?

    The octet at the cursor, without advancing.

    Cursor::peek_at

    fn Cursor::peek_at(self : Cursor, ahead : Int) -> Byte?

    The octet ahead positions past the cursor, if any.

    Cursor::position

    fn Cursor::position(self : Cursor) -> Int

    Current byte offset.

    Cursor::skip_lws

    fn Cursor::skip_lws(self : Cursor) -> Int

    Skips zero or more SP/HTAB.

    Cursor::skip_sws

    fn Cursor::skip_sws(self : Cursor) -> Int

    Skips "optional whitespace" in the RFC 3261 sense: LWS, and any number of folds, repeatedly. Returns the number of octets consumed.

    Cursor::starts_with

    fn Cursor::starts_with(self : Cursor, lit : String) -> Bool

    Case-sensitive literal match at the cursor.

    Cursor::starts_with_ignore_case

    fn Cursor::starts_with_ignore_case(self : Cursor, lit : String) -> Bool

    ASCII case-insensitive literal match at the cursor.

    DiagCode

    pub(all) enum DiagCode {
    MissingRequiredHeader
    MissingViaBranch
    CSeqMethodMismatch
    ContentLengthMismatch
    MultipleContentLengthValues
    DuplicateSingleValueHeader
    UnsupportedVersion
    StatusCodeOutOfRange
    ScalarOutOfRange
    UnknownRequestUriScheme
    TrailingOctets
    ExtraneousSeparator
    UnbracketedUriWithHeaders
    MissingContactInDialogRequest
    UnsupportedExtension
    EscapedHeadersInRequestUri
    MalformedDate
    MalformedFieldValue
    InvalidUtf8
    }

    Diagnostic codes. Deliberately fine-grained: a coarse "invalid message" verdict cannot show a reviewer which rule fired.
    impl Show for DiagCode

    DiagCode::output

    fn DiagCode::output(self : DiagCode, logger : &Logger) -> Unit

    DiagCode::to_string

    fn DiagCode::to_string(self : DiagCode) -> String

    Diagnostic

    pub struct Diagnostic {
    code : DiagCode
    severity : Severity
    offset : Int
    detail : String
    }

    impl Show for Diagnostic

    Diagnostic::code

    fn Diagnostic::code(self : Diagnostic) -> DiagCode

    The rule that fired.

    Diagnostic::detail

    fn Diagnostic::detail(self : Diagnostic) -> String

    Human-readable explanation. Never part of a contract; matches on code.

    Diagnostic::offset

    fn Diagnostic::offset(self : Diagnostic) -> Int

    Byte offset into the input where the problem was seen.

    Diagnostic::output

    fn Diagnostic::output(self : Diagnostic, logger : &Logger) -> Unit

    Diagnostic::severity

    fn Diagnostic::severity(self : Diagnostic) -> Severity

    Whether this is an error or a warning. Only errors change the verdict.

    Diagnostic::to_string

    fn Diagnostic::to_string(self : Diagnostic) -> String

    HeaderField

    pub struct HeaderField {
    name : Bytes
    value : Bytes
    raw : Bytes
    offset : Int
    line : Int
    }

    One message header field.

    value is the semantic value: folds collapsed to a single SP, leading and trailing whitespace trimmed. raw keeps the exact octets of the field line (without its terminating CRLF) so a caller can round-trip byte-for-byte.

    HeaderField::line

    fn HeaderField::line(self : HeaderField) -> Int

    1-based line number, for reporting.

    HeaderField::name

    fn HeaderField::name(self : HeaderField) -> Bytes

    The field name as written: case preserved, never percent-unescaped.

    HeaderField::name_is

    fn HeaderField::name_is(self : HeaderField, name : String) -> Bool

    Case-insensitive name test, honouring the compact forms in both directions.

    HeaderField::offset

    fn HeaderField::offset(self : HeaderField) -> Int

    Byte offset of the field name in the input.

    HeaderField::raw

    fn HeaderField::raw(self : HeaderField) -> Bytes

    The field line as received, without its terminating CRLF.

    HeaderField::value

    fn HeaderField::value(self : HeaderField) -> Bytes

    The semantic value, with folds collapsed to one SP and outer whitespace trimmed.

    HeaderParam

    pub struct HeaderParam {
    name : Bytes
    value : Bytes?
    raw : Bytes
    }

    One ;name[=value] header parameter following a name-addr.

    HeaderParam::name

    fn HeaderParam::name(self : HeaderParam) -> Bytes

    Returns the parameter name as written on the wire.

    HeaderParam::value

    fn HeaderParam::value(self : HeaderParam) -> Bytes?

    Returns the parameter value, or None for a valueless flag such as ;lr.

    Host

    pub enum Host {
    Name(Bytes)
    Ipv4(Bytes)
    Ipv6Reference(Bytes)
    }

    Host as written. The IPv6 form keeps its brackets, since dropping them makes the rendered URI ambiguous with a bare IPv6 address.
    impl Show for Host

    Host::output

    fn Host::output(self : Host, logger : &Logger) -> Unit

    Host::to_string

    fn Host::to_string(self : Host) -> String

    NameAddr

    pub struct NameAddr {
    raw : Bytes
    display : Bytes?
    display_quoted : Bool
    uri : AddrSpec
    params : Array[HeaderParam]
    had_angle_brackets : Bool
    }

    A parsed From/To/Contact/Route/Record-Route style address value.

    display holds the semantic display name (quotes removed, quoted-pairs resolved); display_quoted records whether the wire form used quotes, since an unquoted display name that needed quotes is a reportable defect.

    NameAddr::display

    fn NameAddr::display(self : NameAddr) -> Bytes?

    Returns the display name, or None when the value had none.

    NameAddr::display_quoted

    fn NameAddr::display_quoted(self : NameAddr) -> Bool

    True when the wire form quoted the display name.

    NameAddr::had_angle_brackets

    fn NameAddr::had_angle_brackets(self : NameAddr) -> Bool

    True when the value used <> around the addr-spec.

    NameAddr::param

    fn NameAddr::param(self : NameAddr, name : String) -> HeaderParam?

    Looks up a header parameter, then falls back to a URI parameter of the same name. The fallback exists only because fields without <> merge the two namespaces; see the file header.

    NameAddr::params

    fn NameAddr::params(self : NameAddr) -> Array[HeaderParam]

    Returns the header parameters that followed the address.

    NameAddr::raw

    fn NameAddr::raw(self : NameAddr) -> Bytes

    Returns the exact octets this address was parsed from.

    NameAddr::sip_uri

    fn NameAddr::sip_uri(self : NameAddr) -> SipUri?

    The parsed SIP URI, when the address held one.

    NameAddr::tag

    fn NameAddr::tag(self : NameAddr) -> Bytes?

    The tag parameter, which every dialog-forming request must carry.

    NameAddr::uri

    fn NameAddr::uri(self : NameAddr) -> AddrSpec

    Returns the address spec: a parsed SIP URI, or verbatim octets for another scheme.

    QuotedString

    pub struct QuotedString {
    raw : Bytes
    value : Bytes
    }

    A parsed quoted string: the raw octets including the surrounding DQUOTEs, and the unescaped content. Both are kept because round-trip fidelity and semantic comparison are different questions and RFC 4475 tests both.

    QuotedString::raw

    fn QuotedString::raw(self : QuotedString) -> Bytes

    The quoted string including its surrounding DQUOTEs.

    QuotedString::value

    fn QuotedString::value(self : QuotedString) -> Bytes

    The content, with quoted-pairs resolved.

    RequestLine

    pub struct RequestLine {
    verb : Bytes
    uri : RequestUri
    version : Bytes
    raw : Bytes
    }

    A request start line.

    RequestLine::uri

    The request URI, parsed or kept verbatim for a non-SIP scheme.

    RequestLine::verb

    fn RequestLine::verb(self : RequestLine) -> Bytes

    The method token.

    RequestLine::version

    fn RequestLine::version(self : RequestLine) -> Bytes

    The protocol version octets, for example SIP/2.0.

    RequestUri

    pub enum RequestUri {
    Parsed(SipUri)
    Other(Bytes)
    }

    Request-URI as parsed. Other keeps a syntactically valid absolute URI whose scheme is not sip/sips: RFC 3261 permits absoluteURI here, and RFC 4475 §3.3.2 expects such a message to parse and be rejected one layer up.
    impl Show for RequestUri

    RequestUri::output

    fn RequestUri::output(self : RequestUri, logger : &Logger) -> Unit

    RequestUri::to_string

    fn RequestUri::to_string(self : RequestUri) -> String

    Severity

    pub enum Severity {
    Error
    Warning
    }

    impl Show for Severity

    Severity::output

    fn Severity::output(self : Severity, logger : &Logger) -> Unit

    Severity::to_string

    fn Severity::to_string(self : Severity) -> String

    SipMessage

    pub struct SipMessage {
    start : StartLine
    headers : Array[HeaderField]
    body : Bytes
    trailing : Bytes
    consumed : Int
    }

    SipMessage::all_headers

    fn SipMessage::all_headers(self : SipMessage, name : String) -> Array[HeaderField]

    All header fields whose name matches, in wire order. Duplicates are preserved because their presence is itself a diagnosable condition.

    SipMessage::body

    fn SipMessage::body(self : SipMessage) -> Bytes

    The body, framed by Content-Length.

    SipMessage::consumed

    fn SipMessage::consumed(self : SipMessage) -> Int

    Number of octets belonging to this message.

    SipMessage::header

    fn SipMessage::header(self : SipMessage, name : String) -> HeaderField?

    First header field with the given name.

    SipMessage::headers

    fn SipMessage::headers(self : SipMessage) -> Array[HeaderField]

    All header fields in wire order, duplicates included.

    SipMessage::start_line

    fn SipMessage::start_line(self : SipMessage) -> StartLine

    The start line, request or response.

    SipMessage::status_code

    fn SipMessage::status_code(self : SipMessage) -> Int?

    The status code, or None for a request.

    SipMessage::trailing

    fn SipMessage::trailing(self : SipMessage) -> Bytes

    Octets that followed the declared body in the same datagram.

    SipMessage::verb

    fn SipMessage::verb(self : SipMessage) -> Bytes?

    The request method, or None for a response.

    SipUri

    pub struct SipUri {
    raw : Bytes
    scheme : UriScheme
    user : Bytes?
    password : Bytes?
    host : Host
    port : Int?
    params : Array[UriParam]
    headers : Array[UriHeader]
    }

    A parsed URI. raw is the exact octets consumed, so a caller can always recover what was on the wire even when a field is interpreted.

    SipUri::headers

    fn SipUri::headers(self : SipUri) -> Array[UriHeader]

    URI headers in wire order.

    SipUri::host

    fn SipUri::host(self : SipUri) -> Host

    The host in the form it was written, IPv6 brackets included.

    SipUri::is_phone_user

    fn SipUri::is_phone_user(self : SipUri) -> Bool

    True when ;user=phone is present. RFC 3261 §19.1.4 makes this change how the user part is compared, so callers need it as a first-class query.

    SipUri::param

    fn SipUri::param(self : SipUri, name : String) -> UriParam?

    Looks up a URI parameter by name, case-insensitively. Returns the last occurrence, matching how flags such as ;lr are conventionally read.

    SipUri::params

    fn SipUri::params(self : SipUri) -> Array[UriParam]

    URI parameters in wire order.

    SipUri::password

    fn SipUri::password(self : SipUri) -> Bytes?

    The password, or None when absent.

    SipUri::port

    fn SipUri::port(self : SipUri) -> Int?

    The port, or None when absent.

    SipUri::raw

    fn SipUri::raw(self : SipUri) -> Bytes

    The exact octets the URI was parsed from.

    SipUri::scheme

    fn SipUri::scheme(self : SipUri) -> UriScheme

    sip, sips, or another scheme kept verbatim.

    SipUri::user

    fn SipUri::user(self : SipUri) -> Bytes?

    The user part, or None when absent.

    StartLine

    pub enum StartLine {
    Request(RequestLine)
    Response(StatusLine)
    }

    StatusLine

    pub struct StatusLine {
    version : Bytes
    code : Int
    reason : Bytes
    raw : Bytes
    }

    A response start line.

    StatusLine::code

    fn StatusLine::code(self : StatusLine) -> Int

    The numeric status code, saturated when the digits overflow.

    StatusLine::reason

    fn StatusLine::reason(self : StatusLine) -> Bytes

    The reason phrase, which may be empty.

    StatusLine::version

    fn StatusLine::version(self : StatusLine) -> Bytes

    The protocol version octets.

    SyntaxKind

    pub enum SyntaxKind {
    EmptyInput
    UnexpectedEnd
    IllegalOctet
    ExpectedCrlf
    ExpectedToken
    ExpectedQuotedString
    UnterminatedQuotedString
    UnterminatedComment
    TrailingGarbage
    BadEscape
    BadContentLength
    MalformedDate
    }

    Why a syntax error occurred. Kept structural (rather than a prebuilt string) so tests can assert on the kind instead of on prose.
    impl Show for SyntaxKind

    SyntaxKind::output

    fn SyntaxKind::output(self : SyntaxKind, logger : &Logger) -> Unit

    SyntaxKind::to_string

    fn SyntaxKind::to_string(self : SyntaxKind) -> String

    UriHeader

    pub struct UriHeader {
    name : Bytes
    value : Bytes
    }

    One ?name=value URI header. RFC 3261 calls the ?-suffixed section "URI headers", which is a different namespace from message headers.

    UriHeader::name

    fn UriHeader::name(self : UriHeader) -> Bytes

    The URI header name, the part before =.

    UriHeader::value

    fn UriHeader::value(self : UriHeader) -> Bytes

    The URI header value.

    UriParam

    pub struct UriParam {
    name : Bytes
    value : Bytes?
    raw : Bytes
    }

    One ;name[=value] URI parameter. value is None for valueless parameters such as the ubiquitous ;lr.

    UriParam::name

    fn UriParam::name(self : UriParam) -> Bytes

    The parameter name.

    UriParam::value

    fn UriParam::value(self : UriParam) -> Bytes?

    The parameter value, or None for a flag such as ;lr.

    UriScheme

    pub enum UriScheme {
    Sip
    Sips
    Other(Bytes)
    }

    Which scheme a request URI carries. Anything that is a syntactically valid absolute URI but not sip:/sips: is kept as Other, because RFC 3261 §25.1 admits absoluteURI in Request-URI and RFC 4475 §3.3.2/§3.3.3 expect such a message to parse and be rejected at the application layer instead.
    impl Show for UriScheme

    UriScheme::output

    fn UriScheme::output(self : UriScheme, logger : &Logger) -> Unit

    UriScheme::to_string

    fn UriScheme::to_string(self : UriScheme) -> String

    Verdict

    pub enum Verdict {
    WellFormed
    SyntaxRejected
    SemanticRejected
    }

    Three-way verdict, mirroring the split RFC 4475 itself makes between the grammar tests (§3.1.2) and the semantic tests (§3.2, §3.3).
    impl Show for Verdict

    Verdict::output

    fn Verdict::output(self : Verdict, logger : &Logger) -> Unit

    Verdict::to_string

    fn Verdict::to_string(self : Verdict) -> String

    let AT : Byte

    BSLASH

    let BSLASH : Byte

    COLON

    let COLON : Byte

    COMMA

    let COMMA : Byte

    let CR : Byte

    Octet constants used throughout the parser.

    DOT

    let DOT : Byte

    DQUOTE

    let DQUOTE : Byte

    EQUALS

    let EQUALS : Byte

    let GT : Byte

    HTAB

    let HTAB : Byte

    LBRACKET

    let LBRACKET : Byte

    let LF : Byte

    LPAREN

    let LPAREN : Byte

    let LT : Byte

    MINUS

    let MINUS : Byte

    PERCENT

    let PERCENT : Byte

    RBRACKET

    let RBRACKET : Byte

    RPAREN

    let RPAREN : Byte

    SATURATED

    let SATURATED : Int

    Sentinel used when a decimal run exceeds Int range.

    SEMI

    let SEMI : Byte

    SLASH

    let SLASH : Byte

    let SP : Byte

    analyze

    fn analyze(input : Bytes) -> Analysis

    Parses and diagnoses in one step. Never raises: a syntax failure is data, not an exception, because the whole point of the conformance suite is to report on malformed input.

    ascii_bytes_of

    fn ascii_bytes_of(s : String) -> Bytes

    Converts an ASCII-only String to octets, taking the low byte of each UTF-16 code unit.

    This exists because String in this toolchain is UTF-16: b"sip" yields six octets, not three. Every String this module accepts is an ASCII literal (a header name, a scheme), so the low-byte projection is exact; a non-ASCII input is rejected rather than silently truncated.

    ascii_of

    fn ascii_of(b : BytesView) -> String

    Renders octets as a String when they are known to be ASCII/Latin-1, which is the case for every SIP token this library reports. Non-ASCII octets are rendered as . so that a diagnostic never claims a byte value it cannot display.

    bytes_eq_ignore_ascii_case

    fn bytes_eq_ignore_ascii_case(a : BytesView, b : BytesView) -> Bool

    Compares two octet strings case-insensitively over ASCII only, which is the rule RFC 3261 §7.3.1 applies to header field names and to token-valued parameters such as transport names.

    bytes_from_hex

    fn bytes_from_hex(s : String) -> Bytes

    Decodes a hex string into octets. Used by the RFC 4475 fixture, which stores vectors as hex so that non-ASCII and control octets survive a source file.

    bytes_of_view

    fn bytes_of_view(v : BytesView) -> Bytes

    Copies a view into owned octets.

    bytes_view_of

    fn bytes_view_of(b : Bytes) -> BytesView

    Borrows the whole of b as a view.

    contains_ignore_case

    fn contains_ignore_case(hay : BytesView, needle : BytesView) -> Bool

    Case-insensitive (ASCII) substring search. Used by diagnostics that look for a specific escaped octet sequence such as %3F.

    content_length_of

    fn content_length_of(headers : Array[HeaderField]) -> Bytes?

    Finds the Content-Length value, if any, using the first occurrence.

    copy_range

    fn copy_range(src : BytesView, start : Int, end : Int) -> Bytes

    Copies an octet range into a fresh Bytes. Used when a parsed fragment must outlive the input buffer.

    diagnose

    fn diagnose(msg : SipMessage) -> Array[Diagnostic]

    Runs all semantic checks over a successfully parsed message.

    eq_ignore_ascii_case

    fn eq_ignore_ascii_case(a : Byte, b : Byte) -> Bool

    Case-insensitive comparison of ASCII letters.

    hex_of

    fn hex_of(b : BytesView) -> String

    Renders octets as lowercase hex, used by diagnostics and snapshot tests. Built from Chars because Bytes::to_string is the Show instance and would produce b"..." instead of the hex text.

    hex_value

    fn hex_value(b : Byte) -> Int

    Hex value of an ASCII hex digit, or -1 when the octet is not a hex digit.

    is_alnum

    fn is_alnum(b : Byte) -> Bool

    True for ASCII letters and digits.

    is_alpha

    fn is_alpha(b : Byte) -> Bool

    True for ASCII letters.

    is_ctl

    fn is_ctl(b : Byte) -> Bool

    Control octets. RFC 3261 forbids raw CTLs in most productions; they may only appear as quoted-pair escapes (\ followed by %x00-09 / %x0B-0C / %x0E-7F).

    is_digit

    fn is_digit(b : Byte) -> Bool

    True for ASCII 0-9.

    is_hexdig

    fn is_hexdig(b : Byte) -> Bool

    True for ASCII hexadecimal digits in either case.

    is_high_octet

    fn is_high_octet(b : Byte) -> Bool

    %x80-FF. RFC 3261 §25.1 allows these in qdtext, ctext and token-adjacent productions through the UTF8-NONASCII rule, so they must survive round-trip.

    is_lws

    fn is_lws(b : Byte) -> Bool

    Linear white space: SP and HTAB. CRLF that is followed by LWS is handled by the folding logic in token.mbt, not here.

    is_separator

    fn is_separator(b : Byte) -> Bool

    RFC 3261 §25.1 separators. These may not appear inside a token.

    is_sip_2_0

    fn is_sip_2_0(v : Bytes) -> Bool

    True when the version octets are exactly SIP/2.0 ignoring case.

    is_token_char

    fn is_token_char(b : Byte) -> Bool

    RFC 3261 §25.1 token character set. Note this deliberately differs from HTTP's token set: SIP excludes |, &, ^, $, # and includes %, *, +, ` , ', ~.

    is_unreserved

    fn is_unreserved(b : Byte) -> Bool

    unreserved = alphanum / mark; mark = "-" / "_" / "." / "!" / "~" / "*" / "'" / "(" / ")"

    is_valid_utf8

    fn is_valid_utf8(v : BytesView) -> Bool

    Strict UTF-8 well-formedness over a byte range: no overlong encodings, no UTF-16 surrogate halves, no code points beyond U+10FFFF. This is what RFC 3261 §25.1 TEXT-UTF8-TRIM demands of free-text fields.

    looks_like_sip_uri

    fn looks_like_sip_uri(c : Cursor) -> Bool

    True when the URI starting at the cursor begins with sip: or sips:.

    lower_ascii

    fn lower_ascii(b : Byte) -> Byte

    Lowercases an ASCII letter; any other octet passes through.

    parse_comment

    fn parse_comment(c : Cursor) -> Bytes raise SipError

    comment = "(" *(ctext / quoted-pair / comment) ")"

    Comments nest in RFC 3261, which is a classic source of stack-depth bugs in SIP parsers. The implementation keeps an explicit depth counter instead of recursing, and caps it so hostile input cannot exhaust memory.

    parse_header_field

    fn parse_header_field(c : Cursor, line : Int) -> HeaderField raise SipError

    Parses one header field. The cursor must be positioned at the first octet of the field name.

    parse_message

    fn parse_message(input : Bytes) -> SipMessage raise SipError

    Parses a whole message from a datagram or a buffered stream chunk.

    parse_name_addr

    fn parse_name_addr(c : Cursor) -> NameAddr raise SipError

    Parses a name-addr or a bare addr-spec.

    The distinction is made by lookahead rather than by backtracking: if the value starts with sip:/sips:, it is a bare addr-spec (RFC 3261 allows this for From/To); otherwise the leading run is a display name and <> is mandatory.

    parse_name_addr_list

    fn parse_name_addr_list(c : Cursor) -> Array[NameAddr] raise SipError

    Parses a comma-separated list of addresses (Contact, Route, Record-Route).

    Commas inside quoted strings do not split — that is the whole reason a hand-rolled split on , is wrong for SIP.

    parse_quoted_string

    fn parse_quoted_string(c : Cursor) -> QuotedString raise SipError

    quoted-string = SWS DQUOTE *(qdtext / quoted-pair) DQUOTE

    qdtext admits LWS, %x21, %x23-5B, %x5D-7E and UTF8-NONASCII. A raw " or a raw \ outside a quoted-pair is therefore a syntax error, which is what RFC 4475 3.1.2.6 relies on.

    parse_sip_uri

    fn parse_sip_uri(c : Cursor) -> SipUri raise SipError

    Parses a SIP/SIPS URI.

    The @ that separates userinfo from host is the first one that appears before any ?. This is the only rule that keeps sip:host?to=sip:a@b from being read as a userinfo section, and it matches what production stacks do. The residual ambiguity — ? is legal inside a user part — is documented in the README rather than papered over.

    parse_token

    fn parse_token(c : Cursor) -> Bytes raise SipError

    token = 1*(alphanum / "-" / "." / "!" / "%" / "*" / "_" / "+" / "" / "'" / "~")Returns the token's octets. Raises ExpectedToken` when the first octet is not a token character (an empty token is not a legal token).

    range_eq_ignore_case

    fn range_eq_ignore_case(src : BytesView, start : Int, end : Int, lit : String) -> Bool

    Compares a byte range against an ASCII literal, case-insensitively.

    to_bytes

    fn to_bytes(msg : SipMessage) -> Bytes

    Renders a parsed message back to octets without normalising anything that was preserved verbatim (header case, fold positions, header order, body).

    trim_ows

    fn trim_ows(b : BytesView) -> BytesView

    Trims leading and trailing SP/HTAB.

    unescape_once

    fn unescape_once(src : BytesView) -> Bytes

    Decodes % HEX HEX exactly once. Non-escapes are copied through, which keeps %41 -> A and %25 -> % without recursing into the result.