Test 2's fourth column, end to end. The probe repeats its Initial on the RFC 9002 §6.2 PTO ladder (0.67 s, doubling after), so an endpoint that answers only a repeat is not read as silence; a reply that arrives but opens under no key is `quic_closed` with `quic_unreadable_reply` rather than `DROP`; and the run's window was widened for the same measured reason (`MIN_WINDOW`: the ladder plus the PTO the answer to the last repeat needs). The run is pipelined per domain: one task walks a row from its own resolve to its last verdict, the QUIC Initial goes out beside the TCP chain, and every probe takes a permit from the same gate, so a step that hangs costs one permit instead of the rest of its column. The live line and `ProgressBlock` carry the columns that now advance together. One timeout replaces `connect_timeout`/`read_timeout`/`quic_timeout`: the menu's Timeout row, `config.yml` and `state.json` carry one value, and the QUIC window keeps its 6 s floor because the ladder above needs it. A host whose endpoint serves no HTTP/3 is not probed at all: the shipped `quic_unsupported.txt` (both third-party testers agreeing on "no", regenerated with `scripts/quic/online_h3.py`) makes its cell a dash — `quic_unsupported` in `--json` — and the summary counts the column over the rows that can answer it (`14/21 QUIC`, not `14/35`). The ICMP arm comes from the same work: a fake that borrows the client's 5-tuple brings its own ICMP back to the probe's socket, and Windows reports that as `WSAENETRESET` (10052) with no queued message to read, so the number is the whole signature there; `net::icmp_err` on Linux reads the three quoting types (destination unreachable, time exceeded, parameter problem) and names the verdict instead of leaving an errno.
19 KiB
Repository Guidelines
Project Overview
dpi-detector: Rust engine classifying network censorship (TCP/TLS/DNS). Targets:
desktop to 16–32 MB embedded routers. Rewrite of a Python prototype — hence the
Python-shaped config.yml (UPPER_SNAKE keys, Russian comments). This tree = source of
truth for behaviour.
Pre-1.0: CLI, --json shape, module layout unstable — change whenever it makes the code
smaller, clearer or cheaper. Such a change MUST land as ONE commit with code, tests,
README.md.
Terms overloaded here — read from context:
test: diagnostic 0–6 (--tests 2), NOT unit test (cargo test). "test 2 is broken" = the diagnostic.profile: censorship region (--profile ru|ir|cn|global), build profile (--profile release-local|release-router), TLS fingerprint (--fingerprint chrome146, 30 total).
Architecture & Data Flow
Acyclic: classify (stage tracking, DpiStatus, Detail) → net (TCP/TLS,
fingerprints) → dns (UDP/DoH/DoT/SOCKS) → probe (tests 0–6); config/profile
beside. net/ NEVER reaches dns/; dns/ NEVER reaches probe/. Binary owns the
interface — CLI, TUI, --json, i18n — NEVER implements probing.
Flow: main.rs (clap, AppConfig, Messages, panic hook) → runner::run_test_suite
(concurrency gate Arc<Semaphore> via probe::permit) → dpi_core::probe::* per test →
views::render_* table or json::Results → json::Report to stdout/--output.
Runtime: a multi-thread tokio builder — two workers on the router targets (--cfg dpi_router) and one per core elsewhere, DPI_WORKERS overrides (main.rs::worker_threads); blocking work ⇒ spawn_blocking.
Locks: parking_lot; tokio::sync::Mutex ONLY across .await. Errors: thiserror in
core, no anyhow, no unwrap() outside tests. Logs: tracing (WARN; -v ⇒ DEBUG).
Key Directories
crates/dpi-core/src/classify/—types.rs(DpiStatus, 38 variants,ConnectionStage),detail.rs(Detail,AlertKind,StackKind),classifier.rs(error ⇒ verdict),stream.rs(DpiProbeStream).crates/dpi-core/src/net/—tcp.rs,tls.rs(TlsProfile, cert verifier),fingerprint/(30 profiles:shapes/,identity.rs,h2.rs,variant.rs),http.rs(the h1/h2 sender with the fingerprint on it),http_client.rs(the control-plane GET: the public-IP echo and the release manifest),connector.rs(DpiTlsConnector),ja3.rs/ja4.rs,pq_kx.rs,quic.rs(the RFC 9000 Initial: header protection, theCRYPTOstream, the RFC 9001 §5.2 keys),cert_compression.rs,hpke.rs,follow_up.rs,bind.rs,sysinfo/,netinfo.rs(a re-export facade over the last three, nine consumers in the binary).crates/dpi-core/src/dns/—wire.rs(RFC 1035),udp.rs,doh.rs,dot.rs,socks.rs,resolve.rs,cymru.rs(Team Cymru ASN/org over DoH TXT).crates/dpi-core/src/probe/— one module per test:dns_avail.rs(1),domains.rs(2),tcp16.rs(3),whitelist.rs(4),telegram.rs(5),burst.rs(6), and nothing else — the transport those tests drive lives innet/anddns/.quic.rssits beside them as test 2's QUIC column: the probe's own Initial and the reply's classification, not a test of its own.crates/dpi-core/src/config.rs—AppConfig(config.ymlschema),ConfigWarning, embedded data lists.crates/dpi-detector/src/i18n/— ONLY location for user-facing strings.crates/dpi-detector/src/tui/— terminal backend, widgets, width math, key input and the live progress line.views/— one renderer per test, drawn with those widgets.screens/— the interactive menus, which render reports and therefore sit aboveviews/: one direction,screens → views → tui.vendor/— patched upstream crates; each carriesREADME-PATCH.md+PATCH.diff.docs/— English.CI.md(CI, release, supply chain),ADDING_A_PROFILE.md(profile procedure),REFACTORING.md(invariants + phases),OPTIMIZATIONS.md(size log),PROFILES_AND_PROVIDER.md(the measured Cargo profiles per platform and the switch toring),i18n-fa.tsv|csv(dev-only worksheet, Russian header).README.mdis the only Russian document: it is the user manual.tools/,scripts/—fingerprint/(profile harness),diag/dns-ca-report.ps1,bench-strategies.sh(router benchmark),quinn-stand/(the out-of-workspace QUIC stack this repository deliberately does not depend on — see its README),vendor-advisories.sh(the CI's supply-chain gate for the path-patched crates),quic/(the stand that validates test 2's QUIC column against aioquic — seescripts/quic/README.md; its client iscargo run -p dpi-core --example quic_probe_once -- 127.0.0.1 <fingerprint>).
Development Commands
Toolchain from rust-toolchain.toml (rustup, cargo, CI read it).
| Goal | Command |
|---|---|
| Build | cargo build --workspace --locked |
| Test | cargo test --workspace --locked |
| One test | cargo test -p dpi-core test_parse_dns_response_a |
| Live-network test | cargo test -p dpi-core --features live-network test_doh_connect_h2 (the only one that dials dns.google) |
| Lint | cargo clippy --workspace --all-targets --locked -- -D warnings |
| Doc links | RUSTDOCFLAGS="-D warnings" cargo doc --workspace --locked --no-deps |
| Supply chain | cargo deny --locked check bans licenses sources advisories (cargo-deny 0.20.2) |
| Run | cargo run -p dpi-detector -- --tests 2 --lang en |
| Example harness | cargo run --release --example tls_fingerprint -- diff a.hex b.hex |
| JA4 cost | cargo run --profile release-local -p dpi-core --example ja4_cost (prices the legend's JA4 pass in ClientHello builds) |
cargo build --profile release-local --target x86_64-pc-windows-msvc -p dpi-detector # iterate, ~1.5 s
$env:RUSTFLAGS = '-C target-feature=+crt-static'
cargo build --release --target x86_64-pc-windows-msvc # desktop artifact, ~55 s
cargo build --profile release-router --target x86_64-pc-windows-msvc # router artifact, -Oz
- Two profiles ship:
release(O3, thin LTO, 16 cgu) for every desktop row andrelease-router(-Oz, fat LTO, 1 cgu,package.ringat O3) for the three 32-bit router rows —release.ymlpicks per row, the numbers are indocs/PROFILES_AND_PROVIDER.md§7–8. - Iterate with
release-local; a release profile ONLY for an artifact. Measurements + flag-set/cache trap:[profile.release-local]comment inCargo.toml. cargo test/clippy= third unit set, no shared cache. Devopt-level = 0⇒ false timeouts; timing runs MUST userelease-local.- NEVER run a formatter — house style ≠ rustfmt default. Format by hand; NEVER commit whitespace-only reformat of untouched files.
Code Conventions & Common Patterns
Systems rules (non-negotiable)
Rule 1 (pure Rust, zero C/C++) was retired on 2026-10-01, when the crypto provider
became ring — a C and assembly dependency that was measured to build for the
router targets and to move the verdicts of the base-form probes, which is why the
rule went rather than the provider (docs/PROFILES_AND_PROVIDER.md). The rule
numbers are stable identifiers quoted from code comments and CI, so they do not
shift: there is simply no Rule 1 any more.
Rule 2 — Memory discipline. Stream/slice, NEVER buffer whole streams; no
tmpfs temp files without an explicit CLI path; decoders capped —
net/cert_compression.rs refuses a zstd window > 1 MiB before ruzstd allocates.
The old "3–6 MB binary + RSS" ceiling is gone: size and RSS are still measured and
logged (docs/OPTIMIZATIONS.md), but they are a goal, not a rule.
Rule 3 — Classification fidelity. Every TCP/TLS probe wraps in DpiProbeStream,
stages TcpConnecting → TcpConnected → TlsClientHelloSent → TlsHandshakeDone → HttpPayload. Reset/EOF at TlsClientHelloSent && bytes_recv == 0 MUST be
DpiStatus::TlsRst, never a generic connection error; connect timeout MUST be
SynDropped. Middlebox accuracy = the product.
Rule 4 — Signals stay Latin. Badges (DpiStatus::display_label()) + RFC protocol
tokens (TCP, TLS, DoH, SNI, ClientHello) NEVER translated — RTL keeps numbers
and IPs in reading order, one log line greps alike for all users. Headers, banners,
prompts, --legend prose are translated.
Rule 5 — --json language-independent. status = snake_case
DpiStatus::as_str() (ok, syn_dropped, tls_rst, no_ca_bundle); detail =
Detail::code() (tls_drop_handshake, http_403). serde MUST agree with as_str() — a
test pins that. --lang NEVER reaches machine output.
Rule 6 — User-facing strings in i18n. No display text at a call site: add a
Messages field (i18n/messages.rs), fill all four languages — struct literals fail to
compile otherwise. i18n/details.rs = exhaustive match over Detail. Farsi = Latin
"Finglish". Allowed literals: badges, protocol tokens, JSON keys, file names, OS
registry/env keys, Detail codes, raw error text appended after a localized label.
Patterns worth copying
- TUI repaints ONLY via
render::frame_repaint/render::frame_home; padding and erase rules, and why they exist, inframe_repaint's doc comment. NEVER hand-pad a row, NEVER home to(0, 0), NEVER clear the screen first. - Totality tables synced by tests:
DpiStatus::ALL,TlsFingerprint::ALL↔SHAPES,TELEGRAM_DCS,KNOWN_KEYS. A profile without aTlsShaperecord panics inspec(). - Shipped defaults live in the root data files (
config.yml,domains.txt,burst-domains.txt,tcp16.json,whitelist_sni.txt), embedded viainclude_str!; precedence and key handling documented inconfig.rs. - stdout = machine channel:
--jsonwrites a parseable document, nothing else may. Human output viaprint_out/println_out/Emitter::emit(--json-aware); diagnostics viatracing(stderr); TUI owns the screen. A strayprintln!breaks a consumer'sjqand a frame at once. - Sanitize server-controlled bytes (TLS alert text,
Detail::Other, HTTP status lines, cert fields) throughtuihelpers on EVERY render path, error branches included. - Search before writing a helper: two implementations = a bug even when both work. Central:
clean_domain,netinfofacade,probe::permit,render::frame_repaint/frame_home,i18n::{fmt_speed, fmt_size, detail_text},tui::widgetswidth math. Missing capability ⇒ extend the central helper, NEVER fork locally. - Fixtures carry no device data: RFC 5737 (
192.0.2.0/24,198.51.100.0/24,203.0.113.0/24), RFC 3849 (2001:db8::/32), RFC 7042 (00-00-5E-00-53-xx). Captured/proc/conntrack/route output ONLY after replacing every address and MAC — repo is public, history cannot be taken back. - Edit sources with
edit/write, NEVER a script: a Python one-liner hides the diff and silently rewrites line endings. Python fine for reading, filtering, hashing, driving a router over SSH.
Important Files
crates/dpi-detector/src/main.rs— entry: CLI parse, config, i18n, panic hook, menu, per-run loop.crates/dpi-detector/src/runner.rs—run_test_suite: orchestration, list loaders,Reportserialization.crates/dpi-detector/src/json.rs—--jsonpayload,SCHEMA_VERSION.crates/dpi-detector/src/args.rs—CliArgs+ runtime-built clapCommand(localized help).crates/dpi-detector/src/state.rs— what the interactive menu remembers between runs:state.jsonin the per-OS config dir, then$PREFIX/etcon Entware, then beside the binary. Written where a run starts — never on a quit, never by a non-interactive run; CLI flags outrank it,config.ymlis the fallback.crates/dpi-core/src/classify/classifier.rs— error ⇒ verdict decisions.config.yml(root, 430 lines) — shipped default runtime config, embedded viainclude_str!.domains.txt,burst-domains.txt,tcp16.json,whitelist_sni.txt,quic_unsupported.txt— per-test target lists, all embedded; test 6 keeps its own on purpose, andquic_unsupported.txtnames the hosts whose QUIC column test 2 does not probe (the cell prints—and the summary counts only the rest).README.md— user manual (Russian), authoritative CLI reference. MUST update in the same commit as a CLI/--json/Detailchange.Cargo.toml,rust-toolchain.toml,deny.toml,Cross.toml,.cargo/config.toml— build, toolchain, supply-chain, cross-compile policy..github/workflows/,.omp/rules/,.omp/agents/— what CI runs (explained indocs/CI.md); repo-local agent rules and profiles.
Runtime/Tooling Preferences
- Rust only, via cargo. Exactly one feature exists —
dpi-core'slive-network, which gates the one test that needs egress (Testing & QA); everything else is a curated selection in[workspace.dependencies]— e.g.brotli-decompressor/ruzstdfor decode-only RFC 8879/8878 support instead of rustls's compressor-linking features. vendor/= patched upstream, not ours: read the crate'sREADME-PATCH.mdbefore touching it; a change goes back to the fork, NEVER into the vendored copy. Two crates —rustlsandhyper— and neither is a workspace member; rootCargo.tomlexplains why.- Auxiliary tooling lives outside the workspace, writes ONLY under
target/, NEVER invoked by cargo: Python 3 (tools/fingerprint/fingerprint.py), Go (tools/fingerprint/utls, uTLS v1.8.2),tools/diag/*.ps1,scripts/*.sh. - CI, release, toolchain rows, supply chain:
docs/CI.md— read it when changing a dependency, an installer or the build matrix. Nothing there needs running by hand before a push.
Testing & QA
Inline unit tests ONLY in #[cfg(test)] mod tests beside the code — one test module per
source module; no tests/, no benches/, no snapshot or mocking framework, no test
runner. Plain #[test] plus a few #[tokio::test]; names are full sentences
(test_reset_classified_from_os_code_not_text).
-
Dev-dependencies ONLY in
dpi-core(tokio/test-util,brotlifor thecert_compressionround trip). Keep it that way; no mocking crate. -
Bar: 100% pass,
cargo clippy --workspace --all-targets -- -D warnings= 0 warnings — Linux AND Windows (the Linux runner cannot seecfg(windows)code;check.ymlhas the detail). -
Fixtures: root data files via
include_str!(several tests re-parse the shippedconfig.yml, so it cannot drift); DER certificates incrates/dpi-core/src/net/testdata/withgenerate.pyas the regenerator; inline literals for synthetic wire bytes,/proc/nfqws2 text and update JSON. -
Network: EXACTLY one test touches the real internet (
dns/doh.rs::test_doh_connect_h2→dns.google), and it sits behinddpi-core's non-defaultlive-networkfeature so that the plaincargo test --workspace --locked— the CI command — needs no egress at all; run it deliberately withcargo test -p dpi-core --features live-network test_doh_connect_h2. Five async tests use127.0.0.1:0;net/tls.rshandshakes run in-process with fixture certs. No#[ignore]d tests — platform differences viacfg. -
MUST name the failure mode before adding a test: cannot say what a consumer observes on regression ⇒ not a test yet; a regression test MUST trigger the real prior failure path. NEVER assert on source text — reading shipped data (
include_str!("config.yml")) is fine, grepping a.rsfile for a call or comment is not. Tests MUST be full-suite safe: MUST NOT leave env vars, cwd or console mode changed. -
CI adds installer smoke tests, a Windows runner and an artifact round trip;
release.ymlgates the tag before the 11-row matrix — job by job indocs/CI.md. Nothing needs running by hand before a push: a new dependency, an installer edit or a broken doc link is what fails it. -
Fingerprint profiles verified by the out-of-workspace harness, not
cargo test:python tools/fingerprint/fingerprint.py all [profile] python tools/fingerprint/fingerprint.py echo-diff chrome131Re-check a profile whenever its data changes; a difference the harness reports that
tools/fingerprint/README.mddoes not name as deliberate = a bug.docs/ADDING_A_PROFILE.mdlists the five artifact locations a new profile touches.
Verifying a fix, and running an audit
Two passes over the same external guideline (RUST_GUIDELINES.md, andrico21/rmcp-server-kit) produced the same classes of mistake. Each rule below names the case it came from, so the claim can be checked rather than trusted.
- A fix is confirmed by the absence of the old pattern, not the presence of the new one. Grep for what was removed. A search for the new code that finds nothing proves nothing:
net/bind.rskeptpush+last_mut().expect("just pushed")while a check forpush_mutfound none and read that as done, andcast_losslesswas recorded as declared on a hit in the vendored provider's crate root. - While another writer holds a file, the working tree is not evidence.
git diff HEAD -- <path>answers "what changed" and "what was there" at once. Reading a file mid-edit made four correctly capped, deadlined response-body reads look unbounded, and the refutation was wrong. - A measurement is not refuted by an argument. A report said the brotli decoder could be made to ask for a gibibyte; RFC 7932 caps a conforming window at 16 MiB, so the number was "corrected" — and the correction was wrong:
brotli_decompressorenables Large-Window-Brotli, and a window-30 stream was measured asking its allocator for(1 << 30) + 66bytes. - One cargo process at a time. Concurrent builds over one
target/leave metadata-only stubs, and the next build fails withonly metadata stub found for rlib dependency core— which reads as a broken toolchain. Never build during a parallel-edit phase; build once, at integration. On that failure, checkrustc --print sysrootand a plainrustclink before re-diagnosing, and cleartarget/. - Frozen outputs are pinned before the type behind them changes.
--jsontokens anddisplay_label()strings are contracts; the pinning indetail.rscovers one stage string of the set, so a typo in the rest is silent today. - Removing an indirection is not free. Count call sites and owners first: the
i18n::format_bidiwrappers had 35 call sites in seven files, and removing them moved no allocation — the call sites need aStringeither way. Record the decision with its measurement, the waycast_losslessandcargo fmtare recorded. - An audit pass hands its dismissals forward, with reasons. The second pass was productive only because every agent was given the first pass's verdicts: what was fixed, and what was examined and deliberately left alone. Write them where the next pass can find them — the commit message, or a context artifact handed to the agents.
- One writer per file. Route cross-file edits — a signature that moved, a doc link a narrowing broke — through the integration owner. Two writers in one file lose one writer's work silently; the compile error is the only reason it is ever noticed.