imsd 0.2.7 — initial public snapshot

Userspace VoLTE/IMS daemon for mainline Linux phones (developed on the
Fairphone 6): a GLib-free core (SIP, SDP, USIM AKA, IPsec SA setup, RTP
media, call engine) behind a GDBus control daemon, a standalone AMR-WB
media leg, and a Plasma Dialer backend. C++26 modules built with Crafter
Build; the tree is clean under the project's house-style linter
(crafter-build lint) across all three build products.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
This commit is contained in:
Jorijn van der Graaf 2026-07-22 22:53:28 +02:00
commit 6e77823933
31 changed files with 8761 additions and 0 deletions

150
tests/Aka/main.cpp Normal file
View file

@ -0,0 +1,150 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Aka unit tests — the AKAv1-MD5 digest against a vector cross-computed
// with the Python reference, identity assembly, the qmicli/mmcli text
// parsers (fed real device output), and the AUTHENTICATE APDU build/parse.
import std;
import Imsd;
using namespace imsd::aka;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
// `qmicli --uim-get-card-status` in the shape a real FP6 eSIM (slot 2)
// produces.
constexpr std::string_view CardStatus =
"[qrtr://0] Successfully got card status\n"
"Provisioning applications:\n"
"\tPrimary GW: slot '2', application '1'\n"
"Slot [1]:\n"
"\tCard state: 'error: no-atr-received (3)'\n"
"\tUPIN state: 'not-initialized'\n"
"Slot [2]:\n"
"\tCard state: 'present'\n"
"\tApplication [1]:\n"
"\t\tApplication type: 'usim (2)'\n"
"\t\tApplication state: 'ready'\n"
"\t\tApplication ID:\n"
"\t\t\tA0:00:00:00:87:10:02:FF:31:F0:04:89:0E:00:00:01\n"
"\t\tPIN1 state: 'disabled'\n";
constexpr std::string_view MmcliSim =
" Properties | active: yes\n"
" | imsi: 001010123456789\n"
" | iccid: 8900101234567890123\n"
" | operator id: 00101\n"
" | operator name: Test PLMN 1-1\n";
}
int main() {
// ---- AKAv1-MD5 digest: pinned vector (the algorithm was cross-checked
// against the Python reference; this vector is recomputed for the
// synthetic test identity)
{
std::array<std::uint8_t, 8> res{0xa1,0xb2,0xc3,0xd4,0xe5,0xf6,0x07,0x18};
std::string d = DigestAkav1("abc123nonce==", res, "cnonce0123456789", "sip:ims.mnc001.mcc001.3gppnetwork.org", "001010123456789@ims.mnc001.mcc001.3gppnetwork.org", "ims.mnc001.mcc001.3gppnetwork.org");
Check(d == "09fd8f8f7ef716c029666a7c6df1db1e", "AKAv1-MD5 digest matches pinned vector");
}
// ---- identity assembly
{
Identity id = MakeIdentity("001010123456789", "001", "01");
Check(id.mnc == "001", "MNC zero-padded to 3");
Check(id.domain == "ims.mnc001.mcc001.3gppnetwork.org", "domain");
Check(id.impi == "001010123456789@ims.mnc001.mcc001.3gppnetwork.org", "IMPI");
Check(id.impu == "sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org", "IMPU");
Check(id.regUri == "sip:ims.mnc001.mcc001.3gppnetwork.org", "reg URI");
}
// ---- card-status parse (real device output)
{
auto sel = ParseCardStatus(CardStatus);
Check(sel.has_value(), "card status yields a ready USIM");
Check(sel && sel->slot == 2, "slot 2 selected (slot 1 is error/no-atr)");
Check(sel && sel->aid == "A0000000871002FF31F004890E000001", "AID uppercased, colons stripped");
}
Check(!ParseCardStatus("Slot [1]:\n\tCard state: 'absent'\n").has_value(), "no ready USIM -> nullopt");
// ---- mmcli parsers
Check(ParsePrimarySimPath(" SIM | primary sim path: /org/freedesktop/ModemManager1/SIM/0\n") == "/org/freedesktop/ModemManager1/SIM/0", "primary sim path");
{
auto si = ParseSimInfo(MmcliSim);
Check(si.has_value(), "sim info parses");
Check(si && si->imsi == "001010123456789", "imsi");
Check(si && si->mcc == "001" && si->mnc == "01", "mcc/mnc split");
// full pipeline: parsed info -> identity
Identity id = MakeIdentity(si->imsi, si->mcc, si->mnc);
Check(id.domain == "ims.mnc001.mcc001.3gppnetwork.org", "info->identity domain");
}
Check(ParseEquipmentId(" Hardware | equipment id: 359999990000001\n") == "359999990000001", "equipment id");
Check(ImeiUrn("359999990000001") == "urn:gsma:imei:35999999-000000-1", "IMEI -> RFC 7254 URN");
Check(!ImeiUrn("35999999000000").has_value(), "14-digit IMEI rejected");
Check(!ImeiUrn("35999999000000x").has_value(), "non-digit IMEI rejected");
// ---- AUTHENTICATE APDU build + response parse
{
std::vector<std::uint8_t> rnd(16, 0x11), autn(16, 0x22);
std::string apdu = BuildAkaApdu(0x01, rnd, autn);
// CLA=01 INS=88 P1=00 P2=81 Lc=22(34) 10 <rand> 10 <autn>
Check(apdu.starts_with("0188008122"), "APDU header CLA/INS/P1/P2/Lc");
Check(apdu == "0188008122" "10" "11111111111111111111111111111111" "10" "22222222222222222222222222222222", "APDU byte-exact");
Check(BuildGetResponseApdu(0x01, 0x2E) == "01c000002e", "GET RESPONSE APDU");
}
{
// DB | 08 RES | 10 CK | 10 IK | SW1 SW2(9000)
std::vector<std::uint8_t> body = {0xDB, 0x08};
for (int i = 0; i < 8; i++) body.push_back(0xA0 + i); // RES
body.push_back(0x10);
for (int i = 0; i < 16; i++) body.push_back(0xC0 + i); // CK
body.push_back(0x10);
for (int i = 0; i < 16; i++) body.push_back(0xD0 + i); // IK
body.push_back(0x90); body.push_back(0x00); // SW
auto r = ParseAkaResponse(body);
Check(r.has_value(), "AKA response parses");
Check(r && r->res.size() == 8 && r->res[0] == 0xA0, "RES extracted");
Check(r && r->ck.size() == 16 && r->ck[0] == 0xC0, "CK extracted");
Check(r && r->ik.size() == 16 && r->ik[0] == 0xD0, "IK extracted");
}
{
// sync-failure tag (0xDC) or truncated body -> nullopt
std::vector<std::uint8_t> dc = {0xDC, 0x0E, 0x90, 0x00};
Check(!ParseAkaResponse(dc).has_value(), "non-DB tag rejected");
std::vector<std::uint8_t> tr = {0xDB, 0x08, 0x00, 0x90, 0x00};
Check(!ParseAkaResponse(tr).has_value(), "length past end rejected");
}
{
// P-Access-Network-Info from `mmcli --location-get` (real-device
// output shape; TAC/CI zero-padded wider than the 4/7 hex digits
// PANI uses). The mcc+mnc+TAC+ECI concatenation was validated
// against a stock modem's on-wire PANI; values here are synthetic.
constexpr std::string_view Loc =
" --------------------------\n"
" 3GPP | operator mcc: 001\n"
" | operator mnc: 01\n"
" | location area code: 0000\n"
" | tracking area code: 001234\n"
" | cell id: 00ABCDE0\n";
auto p = ParsePani(Loc);
Check(p.has_value(), "PANI parses");
Check(p == "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0", "PANI concatenation mcc+mnc+TAC+ECI");
Check(!ParsePani("no location here").has_value(), "PANI absent fields rejected");
constexpr std::string_view Zeros =
" 3GPP | operator mcc: 001\n"
" | operator mnc: 01\n"
" | tracking area code: 000000\n"
" | cell id: 00000000\n";
Check(!ParsePani(Zeros).has_value(), "PANI all-zero cell rejected");
}
if (Failures == 0) std::println("Aka: all tests passed");
return Failures;
}

530
tests/Engine/main.cpp Normal file
View file

@ -0,0 +1,530 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Engine unit tests — the call state machine driven through recorded
// network dialogs. Outgoing: reliable-18x PRACK, 2xx ACK + media start,
// media-plane far-end hangup, local hang-up (CANCEL before answer, BYE
// after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180
// on the INVITE (reliable when the caller requires 100rel), Accept -> 200
// with the SDP answer at the offer's payload types + media, ACK silence,
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488, ring
// timeout -> 480, BYE both ways. Pure reducer, no I/O.
import std;
import Imsd;
using namespace imsd::engine;
using imsd::msg::Context;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
Context MakeCtx() {
Context c;
c.id = imsd::aka::MakeIdentity("001010123456789", "001", "01");
c.local = "2001:db8::db43";
c.route = "<sip:[2001:db8::105]:6000;lr>";
c.ppi = "tel:+31611111111";
c.securityServer = "ipsec-3gpp; spi-c=1; spi-s=2; port-c=3; port-s=4";
return c;
}
bool Has(const std::vector<Action>& a, Action::Type t) {
for (const auto& x : a) if (x.type == t) return true;
return false;
}
const Action* Find(const std::vector<Action>& a, Action::Type t) {
for (const auto& x : a) if (x.type == t) return &x;
return nullptr;
}
// First SendClient whose text starts with `method `.
const Action* Sent(const std::vector<Action>& a, std::string_view method) {
for (const auto& x : a)
if (x.type == Action::Type::SendClient && x.text.starts_with(method)) return &x;
return nullptr;
}
// Build a response with the machine's own Call-ID (so dialog matching in a
// real shell would hold; the reducer itself doesn't re-check).
std::string R183(std::string_view callid) {
return std::format(
"SIP/2.0 183 Session Progress\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Record-Route: <sip:[2001:db8::105]:6000;lr>\r\n"
"Require: 100rel\r\n"
"RSeq: 1\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 120\r\n\r\n"
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
}
std::string R200(std::string_view callid) {
return std::format(
"SIP/2.0 200 OK\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 120\r\n\r\n"
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
}
std::string Final(std::string_view callid, int code, std::string_view text) {
return std::format(
"SIP/2.0 {} {}\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n",
code, text, callid);
}
// First SendResponse whose text starts with `prefix`.
const Action* Responded(const std::vector<Action>& a, std::string_view prefix) {
for (const auto& x : a)
if (x.type == Action::Type::SendResponse && x.text.starts_with(prefix)) return &x;
return nullptr;
}
// An inbound (terminating) INVITE the way KPN's P-CSCF delivers one:
// two Vias, a Record-Route pair, P-Asserted-Identity, an AMR-WB offer
// at non-default payload types (104/105) with telephone-event.
std::string MtInvite(std::string_view extraHeaders = "") {
std::string sdp =
"v=0\r\no=- 55 55 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27864 RTP/AVP 104 105\r\n"
"a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=fmtp:104 octet-align=1;mode-change-capability=2\r\n"
"a=rtpmap:105 telephone-event/16000\r\n"
"a=fmtp:105 0-15\r\n"
"a=ptime:20\r\na=sendrecv\r\n";
return std::format(
"INVITE sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
"Via: SIP/2.0/UDP [2001:db8::9]:5060;branch=z9hG4bKscscf1\r\n"
"Record-Route: <sip:[2001:db8::105]:5100;lr>\r\n"
"Record-Route: <sip:[2001:db8::9]:5060;lr>\r\n"
"Max-Forwards: 68\r\n"
"From: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"
"Call-ID: mt-call-abc123\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
"P-Asserted-Identity: <tel:+31612345678>\r\n"
"Session-Expires: 1800\r\n"
"{}"
"Content-Type: application/sdp\r\n"
"Content-Length: {}\r\n\r\n{}",
extraHeaders, sdp.size(), sdp);
}
}
int main() {
Context c = MakeCtx();
// ---- full answered call: INVITE -> 100 -> 183(rel)+PRACK -> 200 -> ACK
// + media -> far-end hangup
{
imsd::util::Rng rng(42);
CallMachine m(c, rng, "ims-call-1", "1233", 50004, false);
auto start = m.Start();
Check(Sent(start, "INVITE ") != nullptr, "Start sends INVITE");
Check(Has(start, Action::Type::SetDeadline), "Start arms ring timeout");
Check(m.State() == CallState::Dialing, "starts dialing");
// 100 Trying: nothing changes
auto a100 = m.OnInviteResponse(Final(m.CallId(), 100, "Trying"));
Check(a100.empty(), "100 Trying is a no-op");
Check(m.State() == CallState::Dialing, "still dialing after 100");
// 183 with reliable 100rel + SDP: -> ringing, PRACK
auto a183 = m.OnInviteResponse(R183(m.CallId()));
Check(m.State() == CallState::Ringing, "183 -> ringing");
const Action* stAct = Find(a183, Action::Type::State);
Check(stAct && stAct->state == "ringing" && stAct->reason == "outgoing", "183 emits ringing/outgoing");
const Action* prack = Sent(a183, "PRACK ");
Check(prack != nullptr, "reliable 183 triggers PRACK");
Check(prack && prack->text.contains("RAck: 1 1 INVITE\r\n"), "PRACK RAck");
Check(prack && prack->text.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq 2");
// PRACK targets the remote Contact and routes via the Record-Route
Check(prack && prack->text.starts_with("PRACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "PRACK targets remote Contact");
Check(prack && prack->text.contains("Route: <sip:[2001:db8::105]:6000;lr>\r\n"), "PRACK routes via reversed Record-Route");
// a second identical 183 must NOT re-PRACK the same RSeq
auto a183b = m.OnInviteResponse(R183(m.CallId()));
Check(Sent(a183b, "PRACK ") == nullptr, "duplicate RSeq not re-PRACKed");
// 200 OK: ACK + StartMedia + active
auto a200 = m.OnInviteResponse(R200(m.CallId()));
const Action* ack = Sent(a200, "ACK ");
Check(ack != nullptr, "200 -> ACK");
Check(ack && ack->text.starts_with("ACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "ACK targets remote Contact");
Check(ack && ack->text.contains("To: <sip:1233@ims>;tag=remote7\r\n"), "ACK carries dialog To tag");
const Action* media = Find(a200, Action::Type::StartMedia);
Check(media != nullptr, "200 starts media");
Check(media && media->media.Valid(), "media leg valid");
Check(media && media->media.remoteIp == "2001:db8::9", "media remote ip");
Check(media && media->media.remotePort == 6004, "media remote port");
Check(media && media->media.payloadType == 97, "media pt");
Check(media && media->media.codec == "AMR-WB", "media codec");
const Action* active = Find(a200, Action::Type::State);
Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted");
Check(m.State() == CallState::Active, "state active");
// media leg exits code 3 (downlink dried up) -> BYE + terminated
auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE");
const Action* term = Find(amx, Action::Type::State);
Check(term && term->state == "terminated" && term->reason == "remote-hangup", "media exit -> terminated/remote-hangup");
Check(Has(amx, Action::Type::StopMedia), "stops media");
Check(Has(amx, Action::Type::Deleted), "emits deleted");
Check(m.Terminated(), "terminated");
}
// ---- remote BYE mid-call
{
imsd::util::Rng rng(7);
CallMachine m(c, rng, "ims-call-2", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R200(m.CallId()));
Check(m.State() == CallState::Active, "answered");
std::string bye = std::format(
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/TCP [x]:1;branch=z9hG4bKb\r\n"
"From: <sip:them>;tag=remote7\r\nTo: <sip:me>;tag=it\r\n"
"Reason: SIP;cause=503;text=\"PT: Session timer expired.\"\r\n"
"Call-ID: {}\r\nCSeq: 9 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ab = m.OnRequest(bye);
const Action* resp = Find(ab, Action::Type::SendResponse);
Check(resp && resp->text.starts_with("SIP/2.0 200 OK"), "remote BYE answered 200");
Check(resp && resp->text.contains("CSeq: 9 BYE\r\n"), "200 echoes BYE CSeq");
const Action* t = Find(ab, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
const Action* lg = Find(ab, Action::Type::Log);
Check(lg && lg->text.contains("ended: remote-hangup") && lg->text.contains("Reason: SIP;cause=503"), "teardown log names the peer's Reason");
Check(m.Terminated(), "terminated after remote BYE");
}
// ---- local hang-up before answer sends CANCEL; the 200/487 race
{
imsd::util::Rng rng(9);
CallMachine m(c, rng, "ims-call-3", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R183(m.CallId())); // ringing
auto ah = m.OnHangup();
Check(Sent(ah, "CANCEL ") != nullptr, "hangup while ringing sends CANCEL");
Check(Has(ah, Action::Type::SetDeadline), "arms 487 timeout after CANCEL");
Check(!m.Terminated(), "not terminated until 487");
// a late 200 wins the race: ACK, then BYE, then terminate local-hangup
auto ar = m.OnInviteResponse(R200(m.CallId()));
Check(Sent(ar, "ACK ") != nullptr, "race 200 is ACKed");
Check(Sent(ar, "BYE ") != nullptr, "race 200 then BYE (we cancelled)");
const Action* t = Find(ar, Action::Type::State);
Check(t && t->reason == "local-hangup", "cancel race -> local-hangup");
// no media for a cancelled call
Check(!Has(ar, Action::Type::StartMedia), "cancelled call starts no media");
}
// ---- 487 after CANCEL
{
imsd::util::Rng rng(11);
CallMachine m(c, rng, "ims-call-4", "1233", 50004, false);
m.Start();
m.OnHangup(); // CANCEL while dialing
auto a487 = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
Check(Sent(a487, "ACK ") != nullptr, "487 is ACKed (non-2xx)");
const Action* t = Find(a487, Action::Type::State);
Check(t && t->reason == "local-hangup", "487 -> local-hangup");
Check(m.Terminated(), "terminated on 487");
}
// ---- busy / declined finals
{
for (auto [code, want] : std::vector<std::pair<int, std::string>>{
{486, "refused-or-busy"}, {480, "refused-or-busy"},
{603, "refused-or-busy"}, {500, "error"}, {403, "error"}}) {
imsd::util::Rng rng(code);
CallMachine m(c, rng, "u", "1233", 50004, false);
m.Start();
auto a = m.OnInviteResponse(Final(m.CallId(), code, "x"));
Check(Sent(a, "ACK ") != nullptr, std::format("{} ACKed", code));
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == want, std::format("{} -> {}", code, want));
Check(m.Terminated(), std::format("{} terminates", code));
}
}
// ---- BYE after answer (local hangup during active call)
{
imsd::util::Rng rng(13);
CallMachine m(c, rng, "ims-call-5", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R200(m.CallId()));
auto a = m.OnHangup();
const Action* bye = Sent(a, "BYE ");
Check(bye != nullptr, "active hangup sends BYE");
Check(bye && bye->text.contains("CSeq: 3 BYE\r\n"), "BYE CSeq after ACK=1");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
}
// ---- setup timeout -> CANCEL, then error
{
imsd::util::Rng rng(15);
CallMachine m(c, rng, "ims-call-6", "1233", 50004, false);
m.Start();
auto a1 = m.OnDeadline(); // ring timeout
Check(Sent(a1, "CANCEL ") != nullptr, "ring timeout sends CANCEL");
Check(!m.Terminated(), "not terminated after first timeout");
auto a2 = m.OnDeadline(); // 487 never came
const Action* t = Find(a2, Action::Type::State);
Check(t && t->reason == "error", "second timeout -> error");
Check(m.Terminated(), "terminated on second timeout");
}
// ================= incoming (terminating / UAS) =======================
// ---- full accepted incoming call: INVITE -> 100+180, Accept -> 200 with
// the answer at the offer's payload types + media, ACK, remote BYE
{
imsd::util::Rng rng(21);
CallMachine m(c, rng, "ims-call-7", IncomingInvite{MtInvite()}, 50004);
Check(m.Dir() == Direction::Incoming, "direction incoming");
Check(m.State() == CallState::Incoming, "starts incoming");
Check(m.Number() == "+31612345678", "caller id from P-Asserted-Identity");
Check(m.CallId() == "mt-call-abc123", "call id from INVITE");
auto ai = m.OnInvite();
const Action* trying = Responded(ai, "SIP/2.0 100 Trying");
Check(trying != nullptr, "INVITE -> 100 Trying");
Check(trying && trying->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"), "100 leaves To untagged");
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
Check(ringing != nullptr, "INVITE -> 180 Ringing");
Check(ringing && !ringing->text.contains("RSeq:"), "180 unreliable when 100rel not required");
Check(Has(ai, Action::Type::SetDeadline), "arms ring timeout");
// both Vias echoed, in order
Check(ringing && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::105]:5100") && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::9]:5060"), "180 echoes all Vias");
Check(ringing && ringing->text.find("[2001:db8::105]:5100") < ringing->text.find("[2001:db8::9]:5060"), "180 Via order preserved");
Check(ringing && ringing->text.contains("Record-Route: <sip:[2001:db8::105]:5100;lr>"), "180 returns the Record-Route set");
Check(ringing && ringing->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001." "3gppnetwork.org;user=phone>;tag="), "180 mints a To tag");
// retransmitted INVITE (lost provisional): repeat the 180
auto art = m.OnRequest(MtInvite());
Check(Responded(art, "SIP/2.0 180 Ringing") != nullptr, "INVITE retransmit re-sends the 180");
Check(m.State() == CallState::Incoming, "still incoming");
auto aa = m.OnAccept();
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
Check(ok != nullptr, "Accept -> 200 OK");
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 104 105\r\n"), "answer uses the offer's payload types");
Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt");
Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align");
Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event");
Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes");
Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact");
// the 180 and 200 carry the SAME To tag (one dialog)
if (ringing && ok) {
auto tag = [](const std::string& s) {
std::size_t at = s.find(";tag=", s.find("To:"));
return s.substr(at, s.find('\r', at) - at);
};
Check(tag(ringing->text) == tag(ok->text), "180/200 share the To tag");
}
const Action* media = Find(aa, Action::Type::StartMedia);
Check(media != nullptr, "Accept starts media");
Check(media && media->media.remoteIp == "2001:db8::30c", "media ip from offer");
Check(media && media->media.remotePort == 27864, "media port from offer");
Check(media && media->media.payloadType == 104, "media pt from offer");
const Action* st = Find(aa, Action::Type::State);
Check(st && st->state == "active" && st->reason == "accepted", "Accept emits active/accepted");
Check(m.State() == CallState::Active, "active after Accept");
// a second Accept is a no-op
Check(m.OnAccept().empty(), "double Accept is a no-op");
// the caller's ACK is consumed silently
std::string ack = std::format(
"ACK sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKack\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 1 ACK\r\nContent-Length: 0\r\n\r\n", m.CallId());
Check(m.OnRequest(ack).empty(), "ACK gets no response");
// session-refresh UPDATE without a body: bodyless 200
std::string upd = std::format(
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 2 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto au = m.OnRequest(upd);
const Action* u200 = Responded(au, "SIP/2.0 200 OK");
Check(u200 != nullptr, "bodyless UPDATE answered 200");
Check(u200 && u200->text.contains("Content-Length: 0"), "bodyless UPDATE gets a bodyless 200");
Check(u200 && !u200->text.contains("Session-Expires"), "no Session-Expires in the UPDATE -> none invented in the 200");
// session-audit UPDATE with Session-Expires (KPN B2BUA, ~55 s into
// every call): the 200 MUST echo it (RFC 4028 §9) or the network
// releases with "Session timer expired"
std::string se = std::format(
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu2\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Min-SE: 90\r\nSession-Expires: 1800;refresher=uac\r\n"
"Supported: timer\r\n"
"Call-ID: {}\r\nCSeq: 3 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ase = m.OnRequest(se);
const Action* se200 = Responded(ase, "SIP/2.0 200 OK");
Check(se200 != nullptr, "refresh UPDATE answered 200");
Check(se200 && se200->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires");
Check(se200 && se200->text.contains("Require: timer\r\n"), "200 carries Require: timer");
// remote BYE ends it
std::string bye = std::format(
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKb\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 4 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ab = m.OnRequest(bye);
Check(Responded(ab, "SIP/2.0 200 OK") != nullptr, "remote BYE answered");
const Action* t = Find(ab, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
Check(m.Terminated(), "terminated");
}
// ---- reliable 180 when the caller requires 100rel; PRACK answered
{
imsd::util::Rng rng(23);
CallMachine m(c, rng, "ims-call-8", IncomingInvite{MtInvite("Require: 100rel\r\n")}, 50004);
auto ai = m.OnInvite();
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
Check(ringing && ringing->text.contains("Require: 100rel\r\n") && ringing->text.contains("RSeq: 1\r\n"), "Require: 100rel makes the 180 reliable");
std::string prack = std::format(
"PRACK sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKp\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 2 PRACK\r\nRAck: 1 1 INVITE\r\n"
"Content-Length: 0\r\n\r\n", m.CallId());
auto ap = m.OnRequest(prack);
const Action* p200 = Responded(ap, "SIP/2.0 200 OK");
Check(p200 != nullptr, "PRACK answered 200");
Check(p200 && p200->text.contains("CSeq: 2 PRACK\r\n"), "PRACK 200 echoes CSeq");
Check(m.State() == CallState::Incoming, "PRACK leaves state incoming");
}
// ---- local reject: HangUp while ringing -> 486
{
imsd::util::Rng rng(25);
CallMachine m(c, rng, "ims-call-9", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
auto ah = m.OnHangup();
const Action* busy = Responded(ah, "SIP/2.0 486 Busy Here");
Check(busy != nullptr, "reject sends 486");
Check(busy && busy->text.contains("CSeq: 1 INVITE\r\n"), "486 answers the INVITE");
const Action* t = Find(ah, Action::Type::State);
Check(t && t->reason == "local-hangup", "reject -> local-hangup");
Check(m.Terminated(), "terminated after reject");
}
// ---- remote CANCEL while ringing: 200 the CANCEL, 487 the INVITE
{
imsd::util::Rng rng(27);
CallMachine m(c, rng, "ims-call-10", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
std::string cancel = std::format(
"CANCEL sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: {}\r\nCSeq: 1 CANCEL\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ac = m.OnRequest(cancel);
const Action* c200 = Responded(ac, "SIP/2.0 200 OK");
Check(c200 && c200->text.contains("CSeq: 1 CANCEL\r\n"), "CANCEL answered 200 on its own transaction");
const Action* r487 = Responded(ac, "SIP/2.0 487 Request Terminated");
Check(r487 && r487->text.contains("CSeq: 1 INVITE\r\n"), "the INVITE transaction gets the 487");
const Action* t = Find(ac, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote CANCEL -> remote-hangup");
Check(m.Terminated(), "terminated after remote CANCEL");
}
// ---- offer we cannot serve (no AMR-WB): 488 before ringing
{
imsd::util::Rng rng(29);
std::string sdp =
"v=0\r\no=- 5 5 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27864 RTP/AVP 8\r\na=rtpmap:8 PCMA/8000\r\n";
std::string inv = std::format(
"INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: mt-pcma\r\nCSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\nContent-Length: {}\r\n\r\n{}",
sdp.size(), sdp);
CallMachine m(c, rng, "ims-call-11", IncomingInvite{inv}, 50004);
auto ai = m.OnInvite();
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "non-AMR-WB offer refused with 488");
Check(Responded(ai, "SIP/2.0 180 Ringing") == nullptr, "no 180 for a 488");
Check(m.Terminated(), "terminated on 488");
}
// ---- ring timeout: 480, missed call
{
imsd::util::Rng rng(31);
CallMachine m(c, rng, "ims-call-12", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
auto ad = m.OnDeadline();
Check(Responded(ad, "SIP/2.0 480 Temporarily Unavailable") != nullptr, "ring timeout sends 480");
const Action* t = Find(ad, Action::Type::State);
Check(t && t->reason == "remote-hangup", "timeout reads as missed");
Check(m.Terminated(), "terminated on ring timeout");
}
// ---- our BYE after accepting: UAS dialog fields hold
{
imsd::util::Rng rng(33);
CallMachine m(c, rng, "ims-call-13", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
m.OnAccept();
auto ah = m.OnHangup();
const Action* bye = Sent(ah, "BYE ");
Check(bye != nullptr, "hangup after accept sends BYE");
Check(bye && bye->text.starts_with("BYE sip:origgw@[2001:db8::9]:5060 SIP/2.0"), "BYE targets the caller's Contact");
Check(bye && bye->text.contains("CSeq: 1 BYE\r\n"), "our UAS CSeq space starts at 1");
Check(bye && bye->text.contains("To: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>" ";tag=caller1\r\n"), "BYE To is the caller (their tag)");
Check(bye && bye->text.contains("From: <sip:001010123456789@ims.mnc001.mcc001." "3gppnetwork.org>;tag="), "BYE From is us (our tag)");
// UAS route set: Record-Route in RECEIVED order
Check(bye && bye->text.contains("Route: <sip:[2001:db8::105]:5100;lr>, " "<sip:[2001:db8::9]:5060;lr>\r\n"), "BYE routes via Record-Route in received order");
const Action* t = Find(ah, Action::Type::State);
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
}
// ---- media death after accept releases the call
{
imsd::util::Rng rng(35);
CallMachine m(c, rng, "ims-call-14", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
m.OnAccept();
auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE (incoming too)");
Check(m.Terminated(), "terminated");
}
if (Failures == 0) std::println("Engine: all tests passed");
return Failures;
}

124
tests/Ipsec/main.cpp Normal file
View file

@ -0,0 +1,124 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Ipsec unit tests — the `ip xfrm` setup command sequence (byte-exact
// argv) and the warm-SA reader, fed `ip xfrm state`/`policy` output in the
// exact shape captured from a registered FP6 (addresses/keys synthetic).
import std;
import Imsd;
using namespace imsd::ipsec;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
std::string Join(const std::vector<std::string>& v) {
std::string s;
for (const auto& t : v) { s += t; s += ' '; }
if (!s.empty()) s.pop_back();
return s;
}
// `ip xfrm state` in the shape of a registered FP6's kernel state
// (P-CSCF ::105, UE ...db43).
constexpr std::string_view State =
"src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
"\tproto esp spi 0x0a84ab2f reqid 2 mode transport\n"
"\treplay-window 0 \n"
"\tauth-trunc hmac(sha1) 0x00112233445566778899aabbccddeeff 96\n"
"\tenc cbc(aes) 0xffeeddccbbaa99887766554433221100\n"
"src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
"\tproto esp spi 0x0526f2c1 reqid 2 mode transport\n"
"\tenc cbc(aes) 0xffeeddccbbaa99887766554433221100\n"
"src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
"\tproto esp spi 0x0071acd4 reqid 1 mode transport\n"
"\tlastused 2026-07-19 20:42:01\n"
"src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
"\tproto esp spi 0x0526f2c0 reqid 1 mode transport\n"
"\tlastused 2026-07-19 20:42:01\n";
constexpr std::string_view Policy =
"src 2001:db8::105/128 dst 2001:db8:1111:2222:3333:4444:5555:db43/128 sport 33421 dport 45062 \n"
"\tdir in priority 0 \n"
"\ttmpl src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
"\t\tproto esp reqid 2 mode transport\n"
"src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::105/128 sport 45062 dport 33421 \n"
"\tdir out priority 0 \n"
"\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
"\t\tproto esp reqid 2 mode transport\n"
"src 2001:db8::105/128 dst 2001:db8:1111:2222:3333:4444:5555:db43/128 sport 6000 dport 45061 \n"
"\tdir in priority 0 \n"
"\ttmpl src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
"\t\tproto esp reqid 1 mode transport\n"
"src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::105/128 sport 45061 dport 6000 \n"
"\tdir out priority 0 \n"
"\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
"\t\tproto esp reqid 1 mode transport\n";
}
int main() {
// ---- warm-SA reader against real device output
{
auto sa = ParseExistingSa(State, Policy, "2001:db8::105", "2001:db8:1111:2222:3333:4444:5555:db43");
Check(sa.has_value(), "existing SA parses");
Check(sa && sa->portPs == 6000, "port-s (reqid1 out dport) = 6000");
Check(sa && sa->portPc == 33421, "port-c (reqid2 out dport) = 33421");
Check(sa && sa->spiUc == 0x0071acd4u, "spi_uc (P->UE reqid1)");
Check(sa && sa->spiUs == 0x0a84ab2fu, "spi_us (P->UE reqid2)");
Check(sa && sa->spiPs == 0x0526f2c0u, "spi_ps (UE->P reqid1)");
Check(sa && sa->spiPc == 0x0526f2c1u, "spi_pc (UE->P reqid2)");
Check(sa && sa->securityServer == "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000", "reconstructed Security-Server value");
}
// incomplete input -> nullopt
Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt");
// ---- fresh setup command sequence
{
SaParams p;
p.local = "2001:db8::db43";
p.pcscf = "2001:db8::105";
p.ik = std::vector<std::uint8_t>(16, 0xAB);
p.ck = std::vector<std::uint8_t>(16, 0xCD);
p.spiUc = 100; p.spiUs = 200; p.spiPc = 300; p.spiPs = 400;
p.portUc = 45061; p.portUs = 45062; p.portPs = 6000; p.portPc = 33421;
auto cmds = BuildSetupCommands(p);
Check(cmds.size() == 10, "flush x2 + 4 states + 4 policies");
Check(Join(cmds[0]) == "ip xfrm state flush", "state flush first");
Check(Join(cmds[1]) == "ip xfrm policy flush", "policy flush second");
// reqid1 outbound state: src LOCAL dst P spi=spiPs, IK auth, CK enc
Check(Join(cmds[2]) ==
"ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 "
"proto esp spi 400 mode transport reqid 1 "
"auth-trunc hmac(sha1) 0xabababababababababababababababab 96 "
"enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
"state add reqid1 outbound byte-exact");
// first out policy: sport PORT_UC dport PORT_PS reqid 1
Check(Join(cmds[6]) == "ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " "sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " "dst 2001:db8::105 proto esp reqid 1 mode transport", "policy add out reqid1 byte-exact");
// v4 uses /32
SaParams p4 = p;
p4.local = "10.0.0.2"; p4.pcscf = "10.0.0.1";
auto c4 = BuildSetupCommands(p4);
Check(c4[6][5] == "10.0.0.2/32", "v4 prefix length /32");
}
// null cipher -> cipher_null + empty key arg
{
SaParams p;
p.local = "::2"; p.pcscf = "::1";
p.ik = std::vector<std::uint8_t>(16, 0x01);
p.ck = {};
p.ealg = "null";
auto cmds = BuildSetupCommands(p);
auto& st = cmds[2];
Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key");
}
if (Failures == 0) std::println("Ipsec: all tests passed");
return Failures;
}

314
tests/Messages/main.cpp Normal file
View file

@ -0,0 +1,314 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Messages unit tests — every builder compared byte-for-byte against a
// reference message cross-generated from the Python VoLTE stack with pinned
// tags/branches/SPIs. A divergence here is a wire-format change, i.e. a
// registration or call that the commercial IMS core may reject; these are the
// hardest pins in the project. Two deliberate post-parity divergences:
// the REGISTER Contact carries +sip.instance and the MMTEL ICSI feature tag
// (the Python stack registered without them, which made the network divert
// incoming calls to voicemail — see ContactFeatures in Imsd:Messages), and
// every ICSI feature-tag value is percent-encoded (urn%3Aurn-7%3A...) — the
// TS 24.229 coding and what the stock modem sends on the wire; the Python
// stack's plain-colon form is a different string under RFC 3841 matching
// and left terminating access-domain selection routing incoming calls to
// CS fallback (observed live 2026-07-20).
import std;
import Imsd;
using namespace imsd::msg;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
void Eq(std::string_view got, std::string_view want, std::string_view msg) {
if (got != want) {
std::println(std::cerr, "FAIL: {}\n--- got ---\n{}\n--- want ---\n{}", msg, got, want);
++Failures;
}
}
Context MakeCtx() {
Context c;
c.id = imsd::aka::MakeIdentity("001010123456789", "001", "01");
c.local = "2001:db8::db43";
c.route = "<sip:[2001:db8::105]:6000;lr>";
c.ppi = "tel:+31611111111";
c.securityServer =
"ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; "
"spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000";
c.instanceId = "urn:gsma:imei:35999999-000000-1";
c.panInfo = "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0";
return c;
}
constexpr std::string_view Reg1 =
"REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::db43]:5060;branch=z9hG4bKREGBRANCH123456;rport\r\n"
"Max-Forwards: 70\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=FTAG5678\r\n"
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Call-ID: REGCALLID@2001:db8::db43\r\n"
"CSeq: 1 REGISTER\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:5060>;+sip.instance=\"<urn:gsma:imei:35999999-000000-1>\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n"
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n"
"Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"\", response=\"\"\r\n"
"Require: sec-agree\r\n"
"Proxy-Require: sec-agree\r\n"
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n"
"Supported: sec-agree, path\r\n"
"Expires: 600000\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Content-Length: 0\r\n"
"\r\n";
constexpr std::string_view Reg2 =
"REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKREGBRANCH123456;rport\r\n"
"Max-Forwards: 70\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=FTAG5678\r\n"
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Call-ID: REGCALLID@2001:db8::db43\r\n"
"CSeq: 2 REGISTER\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>;+sip.instance=\"<urn:gsma:imei:35999999-000000-1>\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n"
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n"
"Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"NONCEXYZ\", qop=auth, nc=00000001, cnonce=\"CNONCE0123456789\", response=\"RESP0000\", algorithm=AKAv1-MD5\r\n"
"Require: sec-agree\r\n"
"Proxy-Require: sec-agree\r\n"
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n"
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
"Supported: sec-agree, path\r\n"
"Expires: 600000\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Content-Length: 0\r\n"
"\r\n";
constexpr std::string_view Sdp =
"v=0\r\n"
"o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8::db43\r\n"
"t=0 0\r\n"
"m=audio 50004 RTP/AVP 97 98\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=sendrecv\r\n";
constexpr std::string_view Invite =
"INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKINVBRANCH0000000000;rport\r\n"
"Max-Forwards: 70\r\n"
"Route: <sip:[2001:db8::105]:6000;lr>\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=ITAG123456\r\n"
"To: <sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"
"Call-ID: CALLID789@2001:db8::db43\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n"
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, PRACK, UPDATE, MESSAGE, REFER\r\n"
"Supported: 100rel, timer\r\n"
"Accept-Contact: *;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n"
"P-Preferred-Identity: <tel:+31611111111>\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
"Proxy-Require: sec-agree\r\n"
"Require: sec-agree\r\n"
"Session-Expires: 1800;refresher=uac\r\n"
"Min-SE: 90\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 323\r\n"
"\r\n"
"v=0\r\n"
"o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8::db43\r\n"
"t=0 0\r\n"
"m=audio 50004 RTP/AVP 97 98\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=sendrecv\r\n";
constexpr std::string_view Subscribe =
"SUBSCRIBE sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKSUBBRANCH0000000000;rport\r\n"
"Max-Forwards: 70\r\n"
"Route: <sip:[2001:db8::105]:6000;lr>\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=STAG1234\r\n"
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Call-ID: SUBCALLID@2001:db8::db43\r\n"
"CSeq: 1 SUBSCRIBE\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>\r\n"
"Event: reg\r\n"
"Accept: application/reginfo+xml\r\n"
"Expires: 600000\r\n"
"P-Preferred-Identity: <tel:+31611111111>\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
"Proxy-Require: sec-agree\r\n"
"Require: sec-agree\r\n"
"Content-Length: 0\r\n"
"\r\n";
}
int main() {
Context c = MakeCtx();
// ---- REGISTER (initial + protected), byte-exact vs Python
Eq(BuildRegisterInitial(c, "REGCALLID@2001:db8::db43", "FTAG5678",
"REGBRANCH123456", 7449812u, 176466735u),
Reg1, "unprotected REGISTER");
{
std::string auth = AuthAka(c, "NONCEXYZ", "CNONCE0123456789", "RESP0000");
Eq(BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678",
"REGBRANCH123456", 2, 7449812u, 176466735u, auth),
Reg2, "protected REGISTER");
}
// ---- RuriFor variants
Eq(RuriFor("1233", c.id.domain), "sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri short code");
Eq(RuriFor("+31612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri E.164");
Eq(RuriFor("0612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri Dutch national");
Eq(RuriFor("0031612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri 00 intl");
// ---- SUBSCRIBE (reg event, RFC 3680) — no Python counterpart; pinned
// against the shape of the other protected in-flow requests
Eq(BuildSubscribeReg(c, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000"), Subscribe, "reg-event SUBSCRIBE (no aor: temporary IMPU fallback)");
{
Context c2 = MakeCtx();
c2.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org";
std::string sub = BuildSubscribeReg(c2, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000");
Check(sub.starts_with("SUBSCRIBE sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"), "reg-event SUBSCRIBE aor R-URI");
Check(sub.contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=STAG1234\r\n"), "reg-event SUBSCRIBE aor From");
Check(sub.contains("\r\nTo: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>\r\n"), "reg-event SUBSCRIBE aor To");
}
{
// no identity learned yet: P-Preferred-Identity is omitted, not "<>"
Context c3 = MakeCtx();
c3.ppi.clear();
Check(!BuildSubscribeReg(c3, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000") .contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in SUBSCRIBE");
Dialog d;
d.ruri = RuriFor("1233", c3.id.domain);
d.callid = "CID@x"; d.itag = "it"; d.invBranch = "ib";
Check(!BuildInvite(c3, d, "v=0\r\n", false).contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in INVITE");
}
// ---- INVITE, byte-exact vs Python
{
Dialog d;
d.ruri = RuriFor("1233", c.id.domain);
d.callid = "CALLID789@2001:db8::db43";
d.itag = "ITAG123456";
d.invBranch = "INVBRANCH0000000000";
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer");
}
// ---- in-dialog PRACK/BYE + ACK + CANCEL + 200 (structural checks)
{
Dialog d;
d.ruri = RuriFor("1233", c.id.domain);
d.callid = "CID@x";
d.itag = "it";
d.invBranch = "ib";
d.dialogTo = "<sip:1233@x>;tag=remote99";
d.remoteTarget = "sip:termgw@2001:db8::9";
d.recordRoute = {"<sip:rr1;lr>", "<sip:rr2;lr>"};
// dialog route = reversed record-route
Check(DialogRoute(c, d) == "<sip:rr2;lr>, <sip:rr1;lr>", "dialog route reversed");
std::array<std::string, 1> rack = {"RAck: 42 1 INVITE"};
std::string prack = BuildInDialog(c, d, "PRACK", 2, "prbranch", rack);
Check(prack.starts_with("PRACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "PRACK request-URI = remote target");
Check(prack.contains("Route: <sip:rr2;lr>, <sip:rr1;lr>\r\n"), "PRACK dialog route");
Check(prack.contains("To: <sip:1233@x>;tag=remote99\r\n"), "PRACK To has remote tag");
Check(prack.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq");
Check(prack.contains("RAck: 42 1 INVITE\r\n"), "PRACK RAck");
Check(prack.ends_with("Content-Length: 0\r\n\r\n"), "PRACK empty body");
std::string bye = BuildInDialog(c, d, "BYE", 3, "byebranch");
Check(bye.starts_with("BYE sip:termgw@2001:db8::9 SIP/2.0\r\n"), "BYE line");
Check(bye.contains("CSeq: 3 BYE\r\n"), "BYE CSeq");
std::string ack = BuildAck2xx(c, d, "ackbranch", "<ignored>");
Check(ack.starts_with("ACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "ACK 2xx target");
Check(ack.contains("To: <sip:1233@x>;tag=remote99\r\n"), "ACK 2xx dialog To");
Check(ack.contains("CSeq: 1 ACK\r\n"), "ACK CSeq 1");
std::string cancel = BuildCancel(c, d);
Check(cancel.starts_with(std::format("CANCEL {} SIP/2.0\r\n", d.ruri)), "CANCEL request-URI = ruri");
Check(cancel.contains("branch=z9hG4bKib;rport\r\n"), "CANCEL reuses INVITE branch");
Check(cancel.contains("CSeq: 1 CANCEL\r\n"), "CANCEL CSeq copies INVITE");
}
{
// non-2xx ACK uses the INVITE branch + registration route + resp To
Dialog d;
d.ruri = "sip:x@y"; d.callid = "CID"; d.itag = "it"; d.invBranch = "ib9";
std::string ack = BuildAckNon2xx(c, d, "<sip:x@y>;tag=z");
Check(ack.contains("branch=z9hG4bKib9;rport\r\n"), "non-2xx ACK INVITE branch");
Check(ack.contains(std::format("Route: {}\r\n", c.route)), "non-2xx ACK registration route");
Check(ack.contains("To: <sip:x@y>;tag=z\r\n"), "non-2xx ACK echoes response To");
}
// ---- 200 OK response echoes the request's dialog headers, in order
{
std::string req =
"BYE sip:me@here SIP/2.0\r\n"
"Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n"
"From: <sip:them@far>;tag=T\r\n"
"To: <sip:me@here>;tag=M\r\n"
"Call-ID: xyz@far\r\n"
"CSeq: 7 BYE\r\n"
"Content-Length: 0\r\n\r\n";
std::string r = BuildResponse200(c, req);
Check(r.starts_with("SIP/2.0 200 OK\r\n"), "200 status line");
Check(r.contains("Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n"), "200 echoes Via");
Check(r.contains("Call-ID: xyz@far\r\n"), "200 echoes Call-ID");
Check(r.contains("CSeq: 7 BYE\r\n"), "200 echoes CSeq");
Check(r.ends_with("Content-Length: 0\r\n\r\n"), "200 empty body");
// with an SDP answer: our Contact + application/sdp appear
std::string r2 = BuildResponse200(c, req, Sdp);
Check(r2.contains("Content-Type: application/sdp\r\n"), "200 w/ SDP content-type");
Check(r2.contains("Contact: <sip:001010123456789@[2001:db8::db43]:45062>\r\n"), "200 w/ SDP includes our Contact");
Check(r2.ends_with(Sdp), "200 w/ SDP body");
}
// ---- s53 oracle diff: UUID Contact user-part + User-Agent ------------
{
Context c = MakeCtx();
c.contactUser = "029dd36c-857c-4270-ad7f-0ae70d31d50f";
c.userAgent = "Fairphone_Fairphone 6_FP6.QREL.16.82.0";
std::string r1 = BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2);
Check(r1.contains("Contact: <sip:029dd36c-857c-4270-ad7f-0ae70d31d50f@" "[2001:db8::db43]:5060>"), "reg1 Contact carries contactUser, not the IMSI");
Check(!r1.contains("sip:001010123456789@[2001:db8::db43]"), "reg1 has no IMSI-user Contact when contactUser set");
Check(r1.contains("From: <sip:001010123456789@ims.mnc001.mcc001"), "reg1 From/AOR stays the IMSI IMPU");
Check(r1.contains("User-Agent: Fairphone_Fairphone 6_FP6.QREL.16.82.0\r\n"), "reg1 User-Agent emitted");
std::string r2 = BuildRegisterProtected(c, "cid@h", "ft", "br", 2, 1, 2, "Authorization: x");
Check(r2.contains("Contact: <sip:029dd36c-857c-4270-ad7f-0ae70d31d50f@" "[2001:db8::db43]:45062>"), "reg2 Contact carries contactUser at the protected port");
Check(r2.contains("User-Agent: Fairphone_Fairphone 6_FP6.QREL.16.82.0\r\n"), "reg2 User-Agent emitted");
c.userAgent.clear();
Check(!BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2) .contains("User-Agent:"), "empty userAgent omits the header");
}
if (Failures == 0) std::println("Messages: all tests passed");
return Failures;
}

204
tests/Sdp/main.cpp Normal file
View file

@ -0,0 +1,204 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Sdp unit tests — offer building (byte-exact) and answer parsing
// (payload-type preference, octet-align, address family). The IPv6 answer
// mirrors what a live commercial IMS core actually returns (AMR-WB pt=97
// octet-aligned, IPv6 media gateway).
import std;
import Imsd;
using namespace imsd::sdp;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
}
int main() {
// ---- BuildOffer, IPv6 + preconditions (the shape a VoLTE UE sends)
{
std::string sdp = BuildOffer({
.local = "2001:db8:29e9:a05f::1",
.rtpPort = 22222,
.sessionId = 1234567,
.precond = true,
});
std::string expected =
"v=0\r\n"
"o=- 1234567 1234567 IN IP6 2001:db8:29e9:a05f::1\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8:29e9:a05f::1\r\n"
"t=0 0\r\n"
"m=audio 22222 RTP/AVP 97 98\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=curr:qos local sendrecv\r\n"
"a=curr:qos remote none\r\n"
"a=des:qos mandatory local sendrecv\r\n"
"a=des:qos mandatory remote sendrecv\r\n"
"a=sendrecv\r\n";
Check(sdp == expected, "IPv6 precondition offer is byte-exact");
}
// ---- BuildOffer, IPv4 without preconditions
{
std::string sdp = BuildOffer({
.local = "10.0.0.2",
.rtpPort = 4000,
.sessionId = 42,
.precond = false,
});
Check(sdp.contains("o=- 42 42 IN IP4 10.0.0.2\r\n"), "IPv4 origin line");
Check(sdp.contains("c=IN IP4 10.0.0.2\r\n"), "IPv4 connection line");
Check(!sdp.contains("a=curr:qos"), "no precondition block");
Check(sdp.ends_with("a=sendrecv\r\n"), "trailing sendrecv + CRLF");
}
// ---- ParseAnswer, real-world IPv6 answer
{
Answer a = ParseAnswer(
"v=0\r\n"
"o=- 99 99 IN IP6 2001:db8::307\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8::307\r\n"
"t=0 0\r\n"
"m=audio 35868 RTP/AVP 97 98\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=ptime:20\r\n");
Check(a.ip == "2001:db8::307", "answer IP6");
Check(a.port == 35868, "answer port");
Check(a.payloadType == 97, "answer payload type");
Check(a.octetAlign, "answer octet-align");
Check(a.codec == "AMR-WB", "answer codec");
}
// ---- payload-type preference: AMR remembered, later AMR-WB overrides
{
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 96 97\r\n" "a=rtpmap:96 AMR/8000/1\r\n" "a=rtpmap:97 AMR-WB/16000/1\r\n");
Check(a.payloadType == 97, "AMR-WB preferred over earlier AMR");
Check(a.codec == "AMR-WB", "codec follows the preferred rtpmap");
}
// ---- octet-aligned AMR-WB preferred over an earlier BE AMR-WB
// (the s56 MT-static shape: an offer listing bandwidth-efficient first)
{
Answer a = ParseAnswer(
"c=IN IP6 2001:db8::1\r\n"
"m=audio 5000 RTP/AVP 116 118 110\r\n"
"a=rtpmap:116 AMR-WB/16000/1\r\n"
"a=fmtp:116 mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:118 AMR-WB/16000/1\r\n"
"a=fmtp:118 octet-align=1;mode-change-capability=2\r\n"
"a=rtpmap:110 telephone-event/16000\r\n");
Check(a.payloadType == 118, "octet-aligned AMR-WB wins over earlier BE");
Check(a.octetAlign, "octet-align follows the chosen pt");
Check(a.codec == "AMR-WB", "codec is AMR-WB");
}
// ---- BE-only AMR-WB offer stays BE at the first pt
{
Answer a = ParseAnswer(
"c=IN IP6 2001:db8::1\r\n"
"m=audio 5000 RTP/AVP 116 96\r\n"
"a=rtpmap:116 AMR-WB/16000/1\r\n"
"a=fmtp:116 mode-change-capability=2\r\n"
"a=rtpmap:96 AMR/8000/1\r\n"
"a=fmtp:96 octet-align=1\r\n");
Check(a.payloadType == 116, "BE-only AMR-WB beats octet-aligned AMR");
Check(!a.octetAlign, "BE-only offer negotiates bandwidth-efficient");
}
// ---- AMR-only answer
{
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 96\r\n" "a=rtpmap:96 AMR/8000/1\r\n" "a=fmtp:96 octet-align=1\r\n");
Check(a.payloadType == 96 && a.codec == "AMR", "plain AMR fallback");
Check(a.octetAlign, "octet-align seen for AMR too");
}
// ---- no rtpmap at all -> first payload type, bandwidth-efficient
{
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 0\r\n");
Check(a.payloadType == 8, "no rtpmap -> first payload type");
Check(!a.octetAlign, "no fmtp -> bandwidth-efficient");
Check(a.codec.empty(), "no AMR rtpmap -> codec unknown, not assumed");
}
// ---- degenerate body
{
Answer a = ParseAnswer("v=0\r\n");
Check(a.ip.empty() && a.port == -1 && a.payloadType == -1, "empty answer parses to absent fields");
}
// ---- TelephoneEventPt
{
std::string_view offer =
"m=audio 27864 RTP/AVP 104 105\r\n"
"a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=rtpmap:105 telephone-event/16000\r\n"
"a=rtpmap:106 telephone-event/8000\r\n";
Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate");
Check(TelephoneEventPt(offer, 8000) == 106, "narrowband telephone-event pt");
Check(!TelephoneEventPt(offer, 48000).has_value(), "absent clock rate -> nullopt");
Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt");
}
// ---- BuildAnswer: the offer's own payload types, octet-align mirrored
{
std::string sdp = BuildAnswer({
.local = "2001:db8:29e9:a05f::1",
.rtpPort = 50004,
.sessionId = 777,
.payloadType = 104,
.octetAlign = true,
.codec = "AMR-WB",
.dtmfPt = 105,
});
std::string expected =
"v=0\r\n"
"o=- 777 777 IN IP6 2001:db8:29e9:a05f::1\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8:29e9:a05f::1\r\n"
"t=0 0\r\n"
"m=audio 50004 RTP/AVP 104 105\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=fmtp:104 octet-align=1\r\n"
"a=rtpmap:105 telephone-event/16000\r\n"
"a=fmtp:105 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=sendrecv\r\n";
Check(sdp == expected, "AMR-WB answer is byte-exact");
}
// ---- BuildAnswer: bandwidth-efficient offer, no DTMF, narrowband
{
std::string sdp = BuildAnswer({
.local = "10.0.0.2",
.rtpPort = 4000,
.sessionId = 8,
.payloadType = 96,
.octetAlign = false,
.codec = "AMR",
});
Check(sdp.contains("m=audio 4000 RTP/AVP 96\r\n"), "single-pt m-line");
Check(sdp.contains("a=rtpmap:96 AMR/8000/1\r\n"), "narrowband rtpmap");
Check(!sdp.contains("octet-align"), "bandwidth-efficient stays unmarked");
Check(!sdp.contains("telephone-event"), "no DTMF when the offer had none");
Check(sdp.contains("b=AS:30\r\n"), "narrowband bandwidth");
}
if (Failures == 0) std::println("Sdp: all tests passed");
return Failures;
}

179
tests/Sip/main.cpp Normal file
View file

@ -0,0 +1,179 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Sip unit tests — header access, status-line parsing, granted-expires
// extraction, caller-identity extraction, and the TCP frame buffer (split
// delivery, keepalive pongs, pipelined messages). These pin the module's
// edge-case behavior; see the module header before "fixing" one.
import std;
import Imsd;
using namespace imsd::sip;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
const std::string Resp200 =
"SIP/2.0 200 OK\r\n"
"Via: SIP/2.0/TCP [2001:db8::1]:45061;branch=z9hG4bK1\r\n"
"Via: SIP/2.0/TCP [2001:db8::2]:5060;branch=z9hG4bK2\r\n"
"From: <sip:user@ims.mnc001.mcc001.3gppnetwork.org>;tag=abc\r\n"
"To: <sip:user@ims.mnc001.mcc001.3gppnetwork.org>;tag=def\r\n"
"Call-ID: 12345@host\r\n"
"CSeq: 2 REGISTER\r\n"
"contact: <sip:user@[2001:db8::1]:45061>;expires=600000\r\n"
"P-Associated-URI: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Content-Length: 0\r\n"
"\r\n";
}
int main() {
// ---- Status
Check(Status(Resp200) == 200, "Status parses 200");
Check(Status("SIP/2.0 183 Session Progress\r\n\r\n") == 183, "Status parses 183");
Check(!Status("INVITE sip:+31@x SIP/2.0\r\n\r\n").has_value(), "Status rejects a request start-line");
Check(!Status("SIP/2.0 xx3\r\n").has_value(), "Status rejects non-digits");
// ---- Header / Headers
Check(Header(Resp200, "Call-ID") == "12345@host", "Header finds Call-ID");
Check(Header(Resp200, "call-id") == "12345@host", "Header is case-insensitive (query)");
Check(Header(Resp200, "Contact") == "<sip:user@[2001:db8::1]:45061>;expires=600000", "Header is case-insensitive (message)");
Check(!Header(Resp200, "Route").has_value(), "Header absent -> nullopt");
Check(Header(Resp200, "CSeq") == "2 REGISTER", "Header trims the value");
Check(Headers(Resp200, "Via").size() == 2, "Headers returns every Via");
Check(Headers(Resp200, "Via")[1] == "SIP/2.0/TCP [2001:db8::2]:5060;branch=z9hG4bK2", "Headers keeps order");
// name must sit at line start: "P-Associated-URI" must not match "URI"
Check(!Header(Resp200, "URI").has_value(), "Header anchors at line start");
// bare "Name:" (only CR after the colon) matches with empty value
Check(Header("X-Empty:\r\nX-Real: v\r\n\r\n", "X-Empty") == "", "bare header matches with empty value");
// ---- GrantedExpires
Check(GrantedExpires(Resp200) == 600000, "GrantedExpires from Contact expires=");
Check(GrantedExpires("SIP/2.0 200 OK\r\nExpires: 3600\r\n\r\n") == 3600, "GrantedExpires falls back to Expires");
Check(!GrantedExpires("SIP/2.0 200 OK\r\nExpires: abc\r\n\r\n").has_value(), "non-numeric Expires -> nullopt");
Check(!GrantedExpires("SIP/2.0 200 OK\r\n\r\n").has_value(), "no lifetime -> nullopt");
Check(GrantedExpires("SIP/2.0 200 OK\r\n" "Contact: <sip:a@b>\r\n" "Contact: <sip:c@d>;expires=7200\r\n\r\n") == 7200, "GrantedExpires scans all Contacts");
// ---- FrameBuffer
{
FrameBuffer fb;
fb.Append(Resp200);
auto m = fb.TryExtract();
Check(m == Resp200, "whole message extracts intact");
Check(!fb.TryExtract().has_value(), "buffer empty after extract");
}
{
// split mid-header and mid-body
std::string body = "v=0\r\no=- 1 1 IN IP6 ::1\r\n";
std::string msg = std::format("SIP/2.0 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.size(), body);
FrameBuffer fb;
fb.Append(msg.substr(0, 20));
Check(!fb.TryExtract().has_value(), "incomplete head -> nullopt");
fb.Append(msg.substr(20, msg.size() - 20 - 5));
Check(!fb.TryExtract().has_value(), "incomplete body -> nullopt");
fb.Append(msg.substr(msg.size() - 5));
Check(fb.TryExtract() == msg, "split message reassembles");
}
{
// leading CRLF keepalive pongs are discarded (RFC 5626)
FrameBuffer fb;
fb.Append("\r\n\r\n");
Check(!fb.TryExtract().has_value(), "pure keepalive -> nullopt");
Check(fb.Size() == 0, "keepalive bytes discarded");
fb.Append("\r\n");
fb.Append(Resp200);
Check(fb.TryExtract() == Resp200, "message after keepalive extracts");
}
{
// two pipelined messages come out one at a time, in order
std::string first = "SIP/2.0 100 Trying\r\nContent-Length: 0\r\n\r\n";
FrameBuffer fb;
fb.Append(first + Resp200);
Check(fb.TryExtract() == first, "pipelined: first message");
Check(fb.TryExtract() == Resp200, "pipelined: second message");
Check(!fb.TryExtract().has_value(), "pipelined: then empty");
}
{
// missing Content-Length counts as 0
std::string msg = "SIP/2.0 200 OK\r\nCall-ID: x\r\n\r\n";
FrameBuffer fb;
fb.Append(std::format("{}LEFTOVER", msg));
Check(fb.TryExtract() == msg, "no Content-Length -> body length 0");
Check(fb.Size() == 8, "trailing bytes stay buffered");
}
// ---- CallerId: P-Asserted-Identity preferred, From fallback
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"From: \"Someone\" <sip:+31687654321@ims.example;user=phone>;tag=f\r\n"
"P-Asserted-Identity: <tel:+31612345678>\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "+31612345678", "tel: P-Asserted-Identity wins");
}
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"P-Asserted-Identity: <sip:+31612345678@ims.example;user=phone>\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "+31612345678", "sip: identity yields the user part");
}
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"From: <sip:+31687654321@ims.example;user=phone>;tag=f\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "+31687654321", "From fallback without P-Asserted-Identity");
}
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"From: <sip:anonymous@anonymous.invalid>;tag=f\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "anonymous", "anonymous caller passes through");
}
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"From: tel:+31600000001;tag=f\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "+31600000001", "bare (bracketless) tel URI");
Check(CallerId("INVITE sip:me SIP/2.0\r\nContent-Length: 0\r\n\r\n").empty(), "no identity headers -> empty");
}
// ---- ViaResponsePort: RFC 3261 18.2.2 UDP response routing
{
std::string upd =
"UPDATE sip:me SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:6000;branch=z9hG4bKx;_aluscr_\r\n"
"Content-Length: 0\r\n\r\n";
Check(ViaResponsePort(upd) == 6000, "v6 sent-by port extracted");
std::string rp =
"UPDATE sip:me SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:6000;rport;branch=z9hG4bKx\r\n"
"Content-Length: 0\r\n\r\n";
Check(!ViaResponsePort(rp).has_value(), "rport -> symmetric (nullopt)");
std::string v4 =
"UPDATE sip:me SIP/2.0\r\n"
"Via: SIP/2.0/UDP 192.0.2.1:5062;branch=z9hG4bKx\r\n"
"Content-Length: 0\r\n\r\n";
Check(ViaResponsePort(v4) == 5062, "v4 sent-by port extracted");
std::string np =
"UPDATE sip:me SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105];branch=z9hG4bKx\r\n"
"Content-Length: 0\r\n\r\n";
Check(!ViaResponsePort(np).has_value(), "portless v6 sent-by -> nullopt");
Check(!ViaResponsePort("UPDATE sip:me SIP/2.0\r\n\r\n").has_value(), "no Via -> nullopt");
}
if (Failures == 0) std::println("Sip: all tests passed");
return Failures;
}

100
tests/Util/main.cpp Normal file
View file

@ -0,0 +1,100 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Util unit tests — MD5 against the RFC 1321 test suite, base64/hex
// decoding, host/port formatting, and the deterministic-seed RNG. MD5 and
// base64 are our own (the standard library ships neither) and the AKAv1-MD5
// digest depends on them exactly, so these are pinned hard.
import std;
import Imsd;
using namespace imsd::util;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
}
int main() {
// ---- MD5: the RFC 1321 appendix A.5 test suite
Check(Md5::HexDigest("") == "d41d8cd98f00b204e9800998ecf8427e", "md5 empty");
Check(Md5::HexDigest("a") == "0cc175b9c0f1b6a831c399e269772661", "md5 a");
Check(Md5::HexDigest("abc") == "900150983cd24fb0d6963f7d28e17f72", "md5 abc");
Check(Md5::HexDigest("message digest") == "f96b697d7cb7938d525a2f31aaf161d0", "md5 message digest");
Check(Md5::HexDigest("abcdefghijklmnopqrstuvwxyz") == "c3fcd3d76192e4007dfb496cca67e13b", "md5 alphabet");
Check(Md5::HexDigest("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") == "d174ab98d277d9f5a5611c2c9f419d9f", "md5 alnum");
Check(Md5::HexDigest("12345678901234567890123456789012345678901234567890" "123456789012345678901234567890") == "57edf4a22be3c955ac49da2e2107b67a", "md5 80 digits");
// streaming across a block boundary must equal a single-shot digest
{
std::string big(200, 'x');
Md5 m;
m.Update(std::string_view(big).substr(0, 13));
m.Update(std::string_view(big).substr(13, 100));
m.Update(std::string_view(big).substr(113));
Check(ToHex(m.Digest()) == Md5::HexDigest(big), "md5 streaming == oneshot");
}
// ---- base64 decode
{
auto d = FromBase64("aGVsbG8=");
Check(d.has_value(), "b64 decodes");
Check(std::string(d->begin(), d->end()) == "hello", "b64 hello");
}
{
// 32-byte RAND||AUTN style payload round-trips through hex->b64->decode
auto d = FromBase64("TWFuIGlzIGRpc3Rpbmd1aXNoZWQ=");
Check(d && std::string(d->begin(), d->end()) == "Man is distinguished", "b64 longer");
}
Check(!FromBase64("****").has_value(), "b64 rejects junk");
// ---- hex
{
auto d = FromHex("00ff10");
Check(d && d->size() == 3 && (*d)[1] == 0xFF, "hex decodes");
auto c = FromHex("A0:00:00:00:87");
Check(c && c->size() == 5 && (*c)[0] == 0xA0, "hex skips colons (APDU form)");
Check(!FromHex("abc").has_value(), "hex rejects odd length");
Check(!FromHex("zz").has_value(), "hex rejects bad char");
std::array<std::uint8_t, 3> b{0x0a, 0x84, 0xab};
Check(ToHex(b) == "0a84ab", "tohex");
}
// ---- host/port + is6
Check(Is6("2001:db8::1") && !Is6("10.0.0.1"), "is6");
Check(HostPort("2001:db8::1", 45061) == "[2001:db8::1]:45061", "hostport v6");
Check(HostPort("10.0.0.1", 5060) == "10.0.0.1:5060", "hostport v4");
// ---- RNG: deterministic seed reproducibility + alphabet + length
{
Rng a(1234), b(1234);
std::string ta = a.Token(20), tb = b.Token(20);
Check(ta.size() == 20, "token length");
Check(ta == tb, "same seed -> same token");
for (char c : ta)
Check((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'), "token alphabet");
Rng c(9999);
Check(c.Token(20) != ta, "different seed -> different token");
}
// ---- Uuid4: RFC 4122 v4 shape
{
Rng r(42);
std::string u = Uuid4(r);
Check(u.size() == 36, "uuid length");
Check(u[8] == '-' && u[13] == '-' && u[18] == '-' && u[23] == '-', "uuid dashes");
Check(u[14] == '4', "uuid version nibble");
Check(u[19] == '8' || u[19] == '9' || u[19] == 'a' || u[19] == 'b', "uuid variant nibble");
for (std::size_t i = 0; i < u.size(); i++)
if (i != 8 && i != 13 && i != 18 && i != 23) Check((u[i] >= '0' && u[i] <= '9') || (u[i] >= 'a' && u[i] <= 'f'), "uuid hex alphabet");
Rng r2(42);
Check(Uuid4(r2) == u, "same seed -> same uuid");
}
if (Failures == 0) std::println("Util: all tests passed");
return Failures;
}