util: canonical IPv6 text (RFC 5952 §4) and an address-list parser
imsd compares addresses as text against `ip xfrm` output, which prints RFC 5952 form, while PCSCF= and LOCAL= arrive in whatever spelling the user typed. CanonicalIp6 produces the §4 form from an IPv6 literal (brackets accepted; a zone, a port, a dotted-quad tail, a name or an IPv4 address are refused; an IPv4-mapped address spelled in hex comes back in hex, where inet_ntop writes it dotted), CanonicalAddr applies it to IPv6 only, and ParseAddressList turns a comma-separated list into canonical, deduplicated IPv6 literals in order, keeping the entries CanonicalIp6 accepts and naming every entry it refuses — an IPv4 address, a name, an IPv6 literal with a zone or a dotted-quad tail — to a caller who asks for them (duplicates and blanks are dropped silently). Pure std, so the partitions stay free of inet_pton; nothing calls them yet. Tested: crafter-build test, 7 passed.
This commit is contained in:
parent
9bdbb96b02
commit
146d4368d0
3 changed files with 158 additions and 5 deletions
|
|
@ -35,7 +35,7 @@ PipeWire. The verified device/carrier combinations are in the README.
|
||||||
|---|---|
|
|---|---|
|
||||||
| `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. |
|
| `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. |
|
||||||
| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven partitions below. |
|
| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the seven partitions below. |
|
||||||
| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). |
|
| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, the RFC 5952 §4 IPv6 text (`CanonicalIp6`/`CanonicalAddr`; an IPv4-mapped address spelled in hex stays hex, where §5 and `inet_ntop` write it dotted) and the comma-separated address-list parser (`ParseAddressList`), hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). |
|
||||||
| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. |
|
| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. |
|
||||||
| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. |
|
| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. |
|
||||||
| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. |
|
| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. |
|
||||||
|
|
@ -215,7 +215,9 @@ commit of a range (§11), is the project's definition of green.
|
||||||
|
|
||||||
What the suites pin:
|
What the suites pin:
|
||||||
|
|
||||||
- `Util` — tokens, IPv6 detection and host/port formatting, hex/base64, MD5.
|
- `Util` — tokens, IPv6 detection and host/port formatting, the RFC 5952 §4
|
||||||
|
form (the RFC's §4.1–4.2.3 examples, idempotence on `inet_ntop`-shaped
|
||||||
|
text, what is refused), the address-list parser, hex/base64, MD5.
|
||||||
- `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli`
|
- `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli`
|
||||||
text parsers, the AUTHENTICATE APDU and its response parser.
|
text parsers, the AUTHENTICATE APDU and its response parser.
|
||||||
- `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the
|
- `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
// lint-disable-file fixed-width-types
|
// lint-disable-file fixed-width-types
|
||||||
/*
|
/*
|
||||||
Imsd:Util — small self-contained primitives the rest of the engine builds on:
|
Imsd:Util — small self-contained primitives the rest of the engine builds on:
|
||||||
random SIP tokens, IPv6 detection + host/port formatting, hex/base64 decoding,
|
random SIP tokens, IPv6 detection + host/port formatting, the RFC 5952
|
||||||
and MD5 (RFC 1321). None of the standard library ships these last three, and
|
canonical IPv6 text and the address-list parser, hex/base64 decoding, and MD5
|
||||||
|
(RFC 1321). None of the standard library ships these last three, and
|
||||||
the IMS AKAv1-MD5 digest (Imsd:Aka) needs MD5 over raw bytes plus base64 to
|
the IMS AKAv1-MD5 digest (Imsd:Aka) needs MD5 over raw bytes plus base64 to
|
||||||
unwrap the RAND||AUTN nonce, so they live here rather than pulling in a crypto
|
unwrap the RAND||AUTN nonce, so they live here rather than pulling in a crypto
|
||||||
dependency. Pure std C++ (no GLib, no I/O); behavior is pinned by tests/Util.
|
dependency. Pure std C++ (no GLib, no I/O); behavior is pinned by tests/Util.
|
||||||
|
|
@ -40,6 +41,114 @@ export namespace imsd::util {
|
||||||
: std::format("{}:{}", addr, port);
|
: std::format("{}:{}", addr, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The RFC 5952 §4 text of an IPv6 address: lowercase hex, no leading
|
||||||
|
// zeros, the longest run of two or more zero fields as "::" (the first
|
||||||
|
// on a tie), never a single zero field. This is the form iproute2 and
|
||||||
|
// inet_ntop print, so addresses canonicalised here compare as text
|
||||||
|
// against `ip xfrm` output whatever spelling they arrived in (an
|
||||||
|
// upper-case or uncompressed PCSCF= in /etc/imsd.env never matched the
|
||||||
|
// kernel's text before). Surrounding brackets are accepted. nullopt for
|
||||||
|
// anything that is not a plain IPv6 literal: a zone ("%dev"), a "]:port",
|
||||||
|
// a dotted-quad tail ("::ffff:1.2.3.4" — RFC 5952 §5 keeps that dotted and
|
||||||
|
// an IPv6-only PDN never produces it; the same address spelled in hex,
|
||||||
|
// "::ffff:a00:1", is accepted and comes back in hex — §4 form, where
|
||||||
|
// inet_ntop prints it dotted), two "::", more than eight fields,
|
||||||
|
// a field over four digits, an IPv4 address, a name.
|
||||||
|
inline std::optional<std::string> CanonicalIp6(std::string_view s) {
|
||||||
|
if (s.size() >= 2 && s.front() == '[' && s.back() == ']') s = s.substr(1, s.size() - 2);
|
||||||
|
if (s.empty()) return std::nullopt;
|
||||||
|
auto nibble = [](char c) -> int {
|
||||||
|
if (c >= '0' && c <= '9') return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||||
|
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
for (char c : s) if (c != ':' && nibble(c) < 0) return std::nullopt;
|
||||||
|
// "a:b" fields of one part (no "::" inside); false on an empty or
|
||||||
|
// over-long field
|
||||||
|
auto fields = [&](std::string_view part, std::vector<std::uint16_t>& out) -> bool {
|
||||||
|
if (part.empty()) return true;
|
||||||
|
std::size_t start = 0;
|
||||||
|
while (true) {
|
||||||
|
std::size_t colon = part.find(':', start);
|
||||||
|
std::string_view f = part.substr(start, colon == std::string_view::npos ? std::string_view::npos : colon - start);
|
||||||
|
if (f.empty() || f.size() > 4) return false;
|
||||||
|
unsigned v = 0;
|
||||||
|
for (char c : f) v = v * 16 + static_cast<unsigned>(nibble(c));
|
||||||
|
out.push_back(static_cast<std::uint16_t>(v));
|
||||||
|
if (colon == std::string_view::npos) return true;
|
||||||
|
start = colon + 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::vector<std::uint16_t> head;
|
||||||
|
std::vector<std::uint16_t> tail;
|
||||||
|
std::size_t dc = s.find("::");
|
||||||
|
if (dc == std::string_view::npos) {
|
||||||
|
if (!fields(s, head) || head.size() != 8) return std::nullopt;
|
||||||
|
} else {
|
||||||
|
if (s.find("::", dc + 1) != std::string_view::npos) return std::nullopt;
|
||||||
|
if (!fields(s.substr(0, dc), head) || !fields(s.substr(dc + 2), tail)) return std::nullopt;
|
||||||
|
if (head.size() + tail.size() > 7) return std::nullopt;
|
||||||
|
}
|
||||||
|
std::array<std::uint16_t, 8> g{};
|
||||||
|
std::copy(head.begin(), head.end(), g.begin());
|
||||||
|
std::copy(tail.begin(), tail.end(), g.end() - static_cast<std::ptrdiff_t>(tail.size()));
|
||||||
|
int bestStart = -1;
|
||||||
|
int bestLen = 1; // a run must beat one field to be compressed
|
||||||
|
for (int i = 0; i < 8;) {
|
||||||
|
if (g[static_cast<std::size_t>(i)] != 0) { i++; continue; }
|
||||||
|
int j = i;
|
||||||
|
while (j < 8 && g[static_cast<std::size_t>(j)] == 0) j++;
|
||||||
|
if (j - i > bestLen) { bestStart = i; bestLen = j - i; }
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
std::string out;
|
||||||
|
for (int i = 0; i < 8;) {
|
||||||
|
if (i == bestStart) {
|
||||||
|
out += "::";
|
||||||
|
i += bestLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!out.empty() && out.back() != ':') out += ':';
|
||||||
|
out += std::format("{:x}", g[static_cast<std::size_t>(i)]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The canonical text of any address imsd compares or logs: an IPv6
|
||||||
|
// literal in RFC 5952 form, anything else (an IPv4 address, a malformed
|
||||||
|
// IPv6 that the caller's own checks will refuse) unchanged.
|
||||||
|
inline std::string CanonicalAddr(std::string_view s) {
|
||||||
|
if (!Is6(s)) return std::string(s);
|
||||||
|
return CanonicalIp6(s).value_or(std::string(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A comma-separated address list (PCSCF_DISCOVERED=) → the canonical IPv6
|
||||||
|
// literals in it, in order, duplicates dropped. Entries that are not an
|
||||||
|
// IPv6 literal (an IPv4 address, a name, garbage) go to `rejected` when
|
||||||
|
// the caller asks for them; blanks are skipped.
|
||||||
|
inline std::vector<std::string> ParseAddressList(std::string_view list, std::vector<std::string>* rejected = nullptr) {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
std::size_t start = 0;
|
||||||
|
while (start <= list.size()) {
|
||||||
|
std::size_t comma = list.find(',', start);
|
||||||
|
std::string_view item = list.substr(start, comma == std::string_view::npos ? std::string_view::npos : comma - start);
|
||||||
|
while (!item.empty() && (item.front() == ' ' || item.front() == '\t')) item.remove_prefix(1);
|
||||||
|
while (!item.empty() && (item.back() == ' ' || item.back() == '\t')) item.remove_suffix(1);
|
||||||
|
if (!item.empty()) {
|
||||||
|
if (auto c = CanonicalIp6(item)) {
|
||||||
|
if (std::find(out.begin(), out.end(), *c) == out.end()) out.push_back(*c);
|
||||||
|
} else if (rejected) {
|
||||||
|
rejected->emplace_back(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (comma == std::string_view::npos) break;
|
||||||
|
start = comma + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// A random token of `n` chars over [a-z0-9] — SIP branch/tag/Call-ID
|
// A random token of `n` chars over [a-z0-9] — SIP branch/tag/Call-ID
|
||||||
// material (the Python rand()). The generator is a parameter so tests can
|
// material (the Python rand()). The generator is a parameter so tests can
|
||||||
// pin output; the default constructor seeds from random_device.
|
// pin output; the default constructor seeds from random_device.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
|
|
||||||
// lint-disable-file fixed-width-types
|
// lint-disable-file fixed-width-types
|
||||||
// Imsd:Util unit tests — MD5 against the RFC 1321 test suite, base64/hex
|
// Imsd:Util unit tests — MD5 against the RFC 1321 test suite, base64/hex
|
||||||
// decoding, host/port formatting, and the deterministic-seed RNG. MD5 and
|
// decoding, host/port formatting, the RFC 5952 canonical IPv6 text and the
|
||||||
|
// address-list parser, and the deterministic-seed RNG. MD5 and
|
||||||
// base64 are our own (the standard library ships neither) and the AKAv1-MD5
|
// base64 are our own (the standard library ships neither) and the AKAv1-MD5
|
||||||
// digest depends on them exactly, so these are pinned hard.
|
// digest depends on them exactly, so these are pinned hard.
|
||||||
import std;
|
import std;
|
||||||
|
|
@ -95,6 +96,47 @@ int main() {
|
||||||
Check(Uuid4(r2) == u, "same seed -> same uuid");
|
Check(Uuid4(r2) == u, "same seed -> same uuid");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- CanonicalIp6: RFC 5952 §4 — the text iproute2/inet_ntop print.
|
||||||
|
// Global addresses from the documentation prefix only (2001:db8::/32); the
|
||||||
|
// link-local one is synthetic.
|
||||||
|
{
|
||||||
|
auto c = [](std::string_view s) { return CanonicalIp6(s).value_or("<nullopt>"); };
|
||||||
|
Check(c("2001:DB8:E000:100::105") == "2001:db8:e000:100::105", "canon upper case");
|
||||||
|
Check(c("2001:0db8:e000:0100:0000:0000:0000:0105") == "2001:db8:e000:100::105", "canon uncompressed, leading zeros");
|
||||||
|
Check(c("2001:db8:e000:100:0:0:0:105") == "2001:db8:e000:100::105", "canon zero fields -> ::");
|
||||||
|
Check(c("2001:db8:0:0:1:0:0:1") == "2001:db8::1:0:0:1", "canon first of two equal runs (5952 4.2.3)");
|
||||||
|
Check(c("2001:0:0:1:0:0:0:1") == "2001:0:0:1::1", "canon the longer run wins (4.2.3)");
|
||||||
|
Check(c("2001:db8:0:1:1:1:1:1") == "2001:db8:0:1:1:1:1:1", "canon a single zero field stays (4.2.2)");
|
||||||
|
Check(c("2001:0db8::0001") == "2001:db8::1", "canon leading zeros dropped (RFC 5952 4.1)");
|
||||||
|
Check(c("2001:db8:0:0:0:0:2:1") == "2001:db8::2:1", "canon the zero run compressed (4.2.1)");
|
||||||
|
Check(c("2001:db8::0:1") == "2001:db8::1", "canon :: takes the whole run (4.2.1)");
|
||||||
|
Check(c("1:2:3:4:5:6:7::") == "1:2:3:4:5:6:7:0", "canon :: standing for one field is written out");
|
||||||
|
Check(c("::1") == "::1", "canon ::1");
|
||||||
|
Check(c("::") == "::", "canon ::");
|
||||||
|
Check(c("1::") == "1::", "canon 1::");
|
||||||
|
Check(c("[2001:db8::1]") == "2001:db8::1", "canon brackets stripped");
|
||||||
|
// idempotence on inet_ntop-shaped input: the holds file and the state
|
||||||
|
// file were written in that shape by 0.3.12 and must still compare
|
||||||
|
for (std::string_view s : {"2001:db8:1111:2222:3333:4444:5555:db43", "2001:db8::105", "2001:db8::1:0:0:1", "fe80::db8:ff:fe00:1"})
|
||||||
|
Check(c(s) == s, std::format("canon idempotent on {}", s));
|
||||||
|
for (std::string_view s : {"2001:db8::1::2", "2001:db8:1", "12345::1", "2001:db8::1%qmapmux0.0", "[fe80::1]:5060", "::ffff:10.0.0.1", "10.0.0.1", "pcscf.example.com", "", ":1::2", "1:::2", "::1:", "1:2:3:4:5:6:7:8:9", "2001:db8::g"})
|
||||||
|
Check(!CanonicalIp6(s), std::format("canon rejects '{}'", s));
|
||||||
|
Check(CanonicalAddr("2001:DB8::105") == "2001:db8::105", "CanonicalAddr v6");
|
||||||
|
Check(CanonicalAddr("10.0.0.1") == "10.0.0.1", "CanonicalAddr v4 untouched");
|
||||||
|
Check(CanonicalAddr("2001:db8::g") == "2001:db8::g", "CanonicalAddr passes a malformed v6 through");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ParseAddressList: the PCSCF_DISCOVERED= hand-off
|
||||||
|
{
|
||||||
|
std::vector<std::string> rejected;
|
||||||
|
auto l = ParseAddressList("2001:DB8::105, 2001:db8::23,,10.1.1.1,2001:db8:0::105, pcscf.example , ", &rejected);
|
||||||
|
Check(l.size() == 2 && l[0] == "2001:db8::105" && l[1] == "2001:db8::23", "list: canonical, ordered, deduped");
|
||||||
|
Check(rejected.size() == 2 && rejected[0] == "10.1.1.1" && rejected[1] == "pcscf.example", "list: rejects reported in order");
|
||||||
|
Check(ParseAddressList("").empty(), "list: empty string");
|
||||||
|
Check(ParseAddressList(" , ,").empty(), "list: blanks only");
|
||||||
|
Check(ParseAddressList("2001:db8::1").size() == 1, "list: single entry, no rejected sink");
|
||||||
|
}
|
||||||
|
|
||||||
if (Failures == 0) std::println("Util: all tests passed");
|
if (Failures == 0) std::println("Util: all tests passed");
|
||||||
return Failures;
|
return Failures;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue