-
Notifications
You must be signed in to change notification settings - Fork 0
Internals Invariants
The rules that must not break. Each entry states the rule, why it exists, what enforces it, and how it fails when broken. If you are about to violate one, the enforcement will usually catch you — but knowing why is faster than reading a CI failure.
Threading detail belongs to Threading; failure semantics — what sipnab does when something goes wrong — to the fault model. This page links out rather than restating.
Rule. Exactly one thread writes DialogStore and StreamStore in a given
run: tui-processor in TUI mode, the main thread in batch mode, thread-local
stores in --cores mode. Everything else reads.
Why. Single-writer discipline is what makes try_read() on the render path
safe and cheap. It also means a data race cannot be introduced by adding a
reader, only by adding a writer.
Enforced by. Structure rather than a lint: the stores are moved into the processing thread's closure at spawn.
The one exception, and the reason it is documented rather than forbidden:
opening a pcap from inside the TUI spawns pcap-load, a second writer against
the live stores. Its whole design — progress via async_messages, never a
blocking read on the render side — exists to make that safe. See
Threading.
Fails as. A second writer turns every try_read() contention into a
dropped frame, and the UI appears to stall under load rather than degrade.
Rule. When a packet needs both stores, lock the dialog store, release it, then lock the stream store. Never hold both write locks simultaneously.
Why. Two locks acquired in opposite orders on two threads is the textbook deadlock; keeping them disjoint makes the ordering question moot.
Enforced by. Convention in the two appliers that take locks at all:
process_packet() on the live path and
run_pcap_load() on the TUI
file-open path (rule 1's documented exception). Each locks a store once,
briefly, and releases it before touching the other. The batch and --cores
appliers hold their stores by &mut and so have no ordering to get wrong.
Nothing rejects a third lock-taking applier that gets it backwards, which is
exactly why the rule is written down here.
Fails as. A hang, not a crash: the TUI stops repainting with no error anywhere.
The sequence is short enough to state exactly, and pcap-load repeats it
packet for packet.
sequenceDiagram
autonumber
participant Proc as processing thread
participant DS as DialogStore
participant SS as StreamStore
Proc->>DS: write()
DS-->>Proc: guard
Proc->>DS: apply SIP message
Proc->>DS: drop guard
Proc->>SS: write()
SS-->>Proc: guard
Proc->>SS: apply SDP links
Proc->>SS: drop guard
Note over Proc,SS: the two guards never overlap — there is no lock order to get wrong
Rule. Protocol behavior lives in
classify_packet(). Appliers apply a PacketAction;
they do not decide what a packet means.
Why. Before the pipeline was unified the four paths had drifted — heuristic RTP discovery and WebSocket-SIP unwrap worked on some and not others, so the same capture gave different answers depending on how it was read.
Enforced by. The PacketAction enum (an applier that ignores a new variant
fails to compile) plus
pipeline_test and
parse_path_test.
Fails as. Silent divergence: --cores 4 and --cores 1 report different
stream counts for the same file, and neither is obviously wrong.
Rule. Any collection keyed by something a remote party controls — Call-ID, SSRC, IP, reassembly key — has a cap and a defined behavior at that cap.
Why. Otherwise a flood of unique keys is a remote OOM, and a capture tool is by definition exposed to attacker-chosen input.
Enforced by.
resource_bounds_test, which floods
each map with unique keys and asserts the cap holds. The dialog store offers
both policies explicitly: rotate=true evicts LRU (in batches, because
one-at-a-time removal from the index is O(n) under sustained pressure) and
rotate=false drops new arrivals once full. The stream store evicts
oldest-out at max_streams; TCP/IP reassembly caps entries, per-datagram size
and per-stream buffered bytes in reassembly.rs.
Fails as. Memory growth under a scan that looks like a leak, on a process that is often running as a long-lived capture.
Rule. Anything that can decrypt media — TLS keylog secrets, SDES keys, derived SRTP session keys — is zeroized on drop and never printed, logged, or serialized to any output surface.
Why. D11. A capture tool holding decryption material is a high-value target on disk and in a core dump.
Enforced by. zeroize on the keylog material in
capture/tls.rs (the crate is pulled in by the
tls feature), a hand-rolled Drop zeroization in
rtp/srtp.rs so non-tls builds do not leak session
keys to freed heap, and redaction on the output paths.
Fails as. Keys recoverable from a crash report or a core file — an exposure with no error message attached to it.
Rule. A signed token names the surface it was minted for (aud), and each
verifier accepts only its own audience. A token minted from
--api-signing-key must never authenticate against HTTP MCP, and vice versa —
including when both surfaces are configured with the same signing key.
Why. The REST API and HTTP MCP read separate flags and separate
environment variables, so an operator putting one secret in both
SIPNAB_API_SIGNING_KEY and SIPNAB_MCP_SIGNING_KEY looks like tidy
configuration. Before audience binding that silently made every API token a
valid MCP token, with no warning and nothing in the logs.
Enforced by. The aud check in
verify_signed(), which requires an s2 token's audience
to equal the verifier's own; the audience is set once per surface in
resolve_api_verifier_config() and
resolve_mcp_verifier_config(), and at mint time in
mint_token(). The version prefix is part of the
signed input, so an s2 token cannot be rewritten as s1 to shed the binding.
An empty configured audience matches nothing, so a verifier built without one
fails closed. Cross-surface rejection is pinned in both directions by tests in
auth.rs and
cli_flag_behavior_test.
Fails as. A token scoped to a read-only metrics scrape quietly granting an AI agent full MCP tool access, or the reverse — a privilege boundary that exists in the documentation but not in the code.
Scope. The check is unconditional: the pre-aud s1 format is no longer
accepted, so there is no token version that reaches a surface without an
audience. Static --api-key / --mcp-token secrets remain audience-less by
design — they are shared secrets, not tokens, and an operator who sets the same
static secret on both surfaces has deliberately shared one credential.
Rule. No MCP tool mutates a store, and every response is size-bounded before it is serialized.
Why. The MCP surface is driven by an LLM agent: it must not be able to change what an operator is looking at, and an unbounded response is a denial-of-service against the agent's context window as much as against sipnab.
Enforced by. shape.rs — DEFAULT_LIMIT 50,
HARD_LIMIT 1000, MAX_BODY_BYTES 4096, applied by
resolve_limit() — with
mcp_stdio_test and
mcp_http_test end to end.
Fails as. An agent that quietly truncates or floods, and an operator who cannot tell which.
Rule. A parking_lot guard must not be held across an await point.
Why. parking_lot guards are not async-aware; holding one across a yield
parks the runtime thread with the lock still taken. In a current-thread runtime
— which is what servers.rs builds — that is an
immediate deadlock.
Enforced by. clippy::await_holding_lock = "deny" in the workspace lint
table. Not a warning: the build fails.
Fails as. The API and MCP servers stop responding while capture keeps running, so the process looks healthy from the outside.
The correct shape is snapshot-then-await, and it is worth seeing in order.
sequenceDiagram
autonumber
participant Req as MCP request
participant Tool as tool handler
participant DS as DialogStore
participant Resp as response
Req->>Tool: call tool
Tool->>DS: read()
DS-->>Tool: guard
Tool->>Tool: project into a summary (owned data)
Tool->>DS: drop guard
Note over Tool: only now may the handler await
Tool->>Resp: await serialization, apply shape.rs bounds
Resp-->>Req: bounded JSON
Rule. Every surface that serializes a dialog or a stream projects through
output/model.rs. No surface builds its own JSON
object for these.
Why. It was implemented five times and had already drifted: MCP said
message_count where CLI and REST said msg_count, and MCP emitted
Debug-formatted Invite where the API emitted INVITE. Consumers wrote
per-surface parsers to compensate.
Enforced by.
summary_consistency_test and
json_schema_test.
Fails as. A field name that means one thing in --json and another over
MCP, discovered by a user's broken script rather than by CI.
Rule. Every store access on the render path is try_read(). The render
pass computes, it does not mutate.
Why. A blocking read on the render thread makes the whole UI hostage to capture throughput. Skipping a frame is always better than freezing one.
Enforced by. sync_caches() and
draw_frame(), which fall back to the previous
snapshot on contention; draw_frame() forces a blocking frame after a bounded
number of skips so the display cannot freeze permanently.
Fails as. A UI that stutters exactly when the operator most needs it — under a flood.
Rule. No parser reachable from packet bytes may panic, unwrap(), or exit.
Malformed input is logged (at most, and rate-limited) and skipped.
Why. D17. Every byte sipnab parses is attacker-controlled, so a panic in a parser is a remote denial of service against a capture process — often one that was left running unattended.
Enforced by. Three layers: the pre-commit gate that rejects
unwrap()/expect() in production code, the always-on
smoke_fuzz_test floor under catch_unwind,
and the coverage-guided targets in
fuzz/fuzz_targets/. Parsers return typed errors —
ParseError, CaptureError — rather than panicking, and
error_types_test pins that surface.
Fails as. A single crafted packet ends the capture, and the evidence you were capturing is what you lose.
They are not enforced by a test, which is precisely why they are written down.
Cite the standard. A new analysis claim names the RFC or ITU recommendation it implements. The pull-request template asks for it directly: "Any new analysis claim is honest and backed by the implementation (cite the RFC/ITU standard where relevant)." Jitter is RFC 3550 §6.4.1 signed transit deltas, not a variance; MOS is an E-model estimate, not a measurement. Saying which one you implemented is the difference between a tool an engineer can trust and one they have to re-derive.
Refute your own claims in place. When a performance claim turns out to be wrong, the page that made it is corrected to say so, in the same page, rather than quietly deleted. Zero-copy payloads does exactly this: it documents the zero-copy spine and then records that the predicted 20–30% hot-path win is refuted and the change is cost-neutral on the measured workload. The architecture is still right for other reasons, and the reader gets to see both. A doc tree that only ever records wins is a doc tree nobody can use to make a decision.
Website · Repository · Issues · Generated from docs/ — edit there, not here.
Getting started
Using the TUI
CLI & automation
Configuration
Integrations (API & MCP)
Development & internals