dpi-detector/docs/REFACTORING.md
Kirill Minovsky c7b9899fbb
Some checks failed
Check / Test & lint (linux) (push) Has been cancelled
Check / Artifact round-trip (push) Has been cancelled
Check / Test & lint (windows) (push) Has been cancelled
Check / Supply chain (push) Has been cancelled
feat: add the bundle's chrome150, chrome124 and safari2601 shapes, give each profile its QUIC Initial header, and let test 6 swap cipher suites
Chrome 150 is the bundle's own `--impersonate chrome150` target and the
record the newest desktop block now points at; Chrome 124 is Chrome 123's
hello with X25519Kyber768Draft00 in front of the others (JA4 answers with
123's key, and the group list is what the row exists for); Safari 26.0.1 is
26.0 with the session ticket gone and zstd dropped from `accept-encoding`.

TlsShape gains a QuicShape: DCID/SCID lengths, the first packet number and
the size the Initial is padded to. Those are the four numbers a censor
reads off an Initial before any of it is decrypted, so the QUIC column now
sends the impersonated client's header instead of this build's eight-byte
one.

Test 6 gains `ciphers:A,B,...`. The suite list is the one field outside the
extension block, and a swap of two suites moves neither the block nor its
offset, so the variant answers whether the *contents* of that list are
compared rather than its position or length. The help text gains the new
value in all four locales.

docs/habr-tspu-fingerprint-blocking.md is the report the shape tables were
rebuilt from: the one-field mutation runs, the dual-tong control that keeps
a sleeping censor from reading as a pass, and the model of the rule the
measurements support.
2026-10-19 18:52:39 +03:00

28 KiB
Raw Permalink Blame History

Refactoring plan

This document describes work on the structure of the codebase. Phases P0–P8 are done (their commits are in §2), P9 was deliberately decided against; section §1 remains a list of invariants, and §4 is the history of the phases: what exactly changed, how it was verified, and which deviations were accepted knowingly. Each phase was conceived as a single commit that can be reverted as a whole without changing behaviour (except for the phases where the behaviour change is the goal itself).

The project is at status 5.0.0-alpha: there are no rigid contracts that cannot be changed. The CLI, the --json shape and the module layout change freely — all that matters is that the codebase becomes smaller and clearer, and that the documentation does not describe a shape the binary no longer produces.

1. Project invariants

They MUST NOT be violated in any phase:

  • Memory discipline. Streams and slices, never a whole stream held in memory; no temporary files without an explicit CLI argument; decoders are capped — the zstd reader refuses a window above 1 MiB before ruzstd is allowed to allocate. Size and RSS are measured and logged (docs/OPTIMIZATIONS.md): a goal, not a rule.
  • Acyclic modules. classify → net → dns → probe, each layer using the one before it and none of them reaching back; config and profile sit beside that chain and the binary's i18n sits outside the core entirely. §3 names the one deliberate config → net edge.
  • Stage classification. Every TCP/TLS probe is wrapped in DpiProbeStream; a reset or EOF after ClientHello is DpiStatus::TlsRst, a connect timeout is DpiStatus::SynDropped.
  • Badges and protocols are Latin-script in every language (OK, BLOCKED, TLS RST, SNI, ClientHello); only headings, banners, menus and --legend are translated.
  • Machine output is language-independent. --json never follows --lang: status and detail are the same snake_case tokens in every language.
  • All interface text lives in dpi-detector::i18n (four blocks: En, Ru, Zh, Fa); not a single human-facing string is written at the call site. There is no text in the core — see phase P5 below.
  • The TUI draws only through render::frame_repaint with render::frame_home: manually padding a line to a fixed width is forbidden (a CJK font counts │, ↑, ← as two columns), the screen is not cleared and the cursor is not moved to (0,0).

2. What has already been done

All phases of the plan except P9 (typed config) are done. One commit — one phase, except for the noted exceptions.

Commit Phase Content
9339a6b — Common helpers in one place, main.rs 1778 → 670 (runner.rs, terminal.rs, menu.rs appeared)
3fc734a — A single dial_tcp, named target loaders, system queries moved into spawn_blocking
60c682a — Documented the deviation on TLS versions in the pinned ClientHello
83ca592 — The menu and the test 6 screen are drawn by the shared panel_to_string, the test table comes from Messages::menu_test_label
2abd399 — The README and the rules no longer present the --json shape and the badges as a frozen contract
1722e16 — ~150 comments describe behaviour rather than "mirroring" the decommissioned prototype
21bb1e8 — This plan was written down
1aebf22 P1.5 (part), P4 (part) Removed the dead probe/tls.rs (196 lines), TCP_NODELAY is enabled by a single helper on every dial
bdd9186 P1 net/version.rs moved to dpi-detector::update; the core no longer depends on i18n
6337df1 P1.5 Public surface of the core: 30 re-exports of dns/mod.rs → 3, RustlsConnector/DpiTlsConnector lowered to pub(crate), removed DpiProbeStream::{into_inner,get_ref,get_mut}, QTYPE_{NS,SOA,PTR,MX}, Language::{code,name,is_rtl}, three dead profile accessors, #![warn(unreachable_pub)] enabled
50afd0e P2 dns/availability.rs (1146) → probe/dns_avail.rs; the core lost the dns → probe and dns → classify edges
6ff72e5 P2.5 detail became the enum classify::Detail with code(); classify/constants.rs removed, classification no longer parses prose, i18n::detail_text is an exhaustive match
81bd6bc P2.6 --json is assembled by the structures in src/json.rs; runner.rs no longer knows about json!
8724e7e P2.7 i18n/mod.rs (1711) → mod.rs + messages.rs + en/ru/zh/fa
750e185 P4 + P4.5 + P7 net/netinfo.rs (988) → http_client + public_ip + sysinfo/{os,bypass}, Cymru moved to probe/cymru.rs (the net → dns cycle is broken); nine TLS factories → TlsProfile + create_tls_config; core panics justified or removed
c7660fb P3 render.rs (3013) and menu.rs (1532) → tui/{backend,widgets,progress,input,screens} + views/*; the markup layer removed
ce546e3 P6 CI gate .github/workflows/check.yml (build + test + clippy on push and PR)
0dc0fa7 P5 i18n moved into the binary; Messages slimmed from 213 fields to 206 (dead strings and the runtime coverage test removed)
ac53995 P8 selection_flags → TestSelection::parse with named fields

Deviations from the plan, recorded deliberately:

  • P4, P4.5 and P7-core landed in a single commit (750e185): they are coupled through net/http_client.rs and the new TlsProfile, and on their own not one part compiles.
  • P4.5 did not shrink the file: net/tls.rs 273 → 296 lines. The surface shrank (nine factories → one), while the two crypto providers, the insecure verifier and the version parsing stayed in place.
  • profile::blockpage_signatures was removed in P1.5: after probe/tls.rs was deleted nobody read it, and the signature list remains in git history until a consumer appears.
  • Messages slimmed down: seven fields were read by nobody (banner_subtitle, stage, bytes, duration, bypass_tools, gateway, detail_write_timeout), five were read only by the coverage test. The test itself was dropped: four literals in four languages will not compile anyway if a field is forgotten.
  • P7 does not reduce panics to zero: 11 unwrap/expect remain outside tests, each with a written reason why it is impossible (the TLS provider, a constant HTTP request, the fingerprint spec table, serde_yaml on an empty mapping, an example utility).
  • P4 decomposed differently from the plan: instead of sysinfo/{os,bypass}.rs, net/public_ip.rs and net/sysinfo/mod.rs also appeared.

3. Target layering

The graph is acyclic, with one named exception: AppConfig::clamp validates tls_fingerprint through net::fingerprint::TlsFingerprint::parse, so config and net meet in both directions on that one line. It is deliberate — the parse has to happen before a run, and the warning it raises is a load-time ConfigWarning — and it is the only production edge config has: the profile parse is TlsFingerprint::from_config, the User-Agent is TlsFingerprint::user_agent_for, and the transport-port table is config's own, pinned against the probe's by test_availability_kind_vocabulary.

The three cycles this section used to list are gone, and the third of them was a misreading:

  • net → dns — gone with P4 (750e185): net/netinfo.rs is a re-export facade over net/http_client, net/public_ip and net/sysinfo — a live one, with nine consumers in the binary — and the Cymru lookup that called query_doh_txt now lives in dns/cymru.rs (§5).
  • dns → probe — gone with P2 (50afd0e): dns/availability.rs no longer exists, it is probe/dns_avail.rs, and fake_ip_type is imported from probe::domains by the layer that owns it.
  • dns → classify — not a cycle: classify is the leaf of the diagram below, so this edge points down the layering; only an edge back up out of dns could close a loop.

Re-checked on the tree rather than read off the phase log:

  • a search for crate::dns under crates/dpi-core/src/net — no matches;
  • a search for crate::probe under crates/dpi-core/src/dns — no matches;
  • a search for crate:: + net/dns/probe under crates/dpi-core/src/classify (brace-elided forms included) — no matches, so the leaf still imports nothing from the layers above it;
  • dns/availability.rs is absent from ls crates/dpi-core/src/dns (cymru doh dot mod resolve socks types udp wire);
  • a search for crate::probe and for crate::net under crates/dpi-core/src/config.rs — the first only inside #[cfg(test)], the second only on the clamp line named above;
  • a search for crate::render and crate::views under crates/dpi-detector/src/tui — no matches, so the terminal layer no longer depends on the screens that draw with it (screens → views → tui).

The layer-crossing imports dns/ makes are all on the allowed side: crate::net::{bind,fingerprint,http,tcp,tls} and the leaf crate::classify. The goal is an acyclic graph:

classify  ←  net  ←  dns  ←  probe
   (leaf: stages and verdicts)  (7 diagnostic tests)
config, profile, i18n — read by every layer above them, never reading back;
  `config`'s one exception is the `clamp` validation named above
the binary: screens → views → tui, and presentation only — no probing

4. Phases

P0 — Reference output snapshot ✅

  • Capture --json for -t 1, -t 2 -d example.com, -t 5 into a file and the set of keys (jq 'paths(scalars)|join(".")') — this is a reference for the eyes, not a contract.
  • Why: P2.5 and P2.6 change the shape of detail and the shape of the JSON; the comparison must be deliberate, not byte-for-byte.
  • Risk: none. Nothing blocks.

P1 — net/version.rs leaves the core for the binary ✅ (bdd9186)

  • What: 244 lines — fetch_latest_version (GitHub Releases API), version_badge_lang, ReleaseInfo, CURRENT_VERSION, is_newer → crates/dpi-detector/src/update.rs.
  • Why: it is an application-level service utility, and it is the only module of the core that uses i18n at all (net/version.rs:1,160), and it also pulls in netinfo::http_get_text.
  • Cost: two imports in the binary (main.rs:9, menu.rs:17) and an edit to net/mod.rs.
  • Verification: the gate + a live run of the menu (version badge in the banner).

P1.5 — Public surface of the core ✅ (6337df1)

The audit showed ~63 pub items with not a single reference outside their own file. In a library crate the compiler does not catch such things, hence a cleanup plus protection against recurrence:

  • Done (1aebf22): probe/tls.rs removed entirely.
  • Remaining — lower to pub(crate) or remove: net/version.rs:154 version_badge (called only from its own test), 27 unused re-exports of dns/mod.rs:10-17 (exactly one is live through a re-export, parse_socks_proxy), net/tls.rs create_insecure_dpi_tls_config_tls13/tls12, net/connector.rs new_insecure_tls13/new_insecure_tls12/new_verifying, classify/stream.rs ProbeState and DpiProbeStream::{into_inner,get_ref,get_mut}, dns/wire.rs QTYPE_NS/SOA/PTR/MX, net/cert_compression.rs covers (tests only), i18n Language::{code,name,is_rtl}, profile default_resolvers/default_doh/blockpage_signatures (tests only), lib.rs aliases PhaseSwitch/BlocksSwitch.
  • Protection: unreachable_pub is enforced from the workspace lint table (Cargo.toml, [workspace.lints.rust]), which dpi-core opts into with [lints] workspace = true; work through the output so the surface does not creep again. The binary crate carries a dated local allow for it until the pub → pub(crate) sweep over dpi-detector lands — its modules are private, so the lint fires on every pub item there.

P2 — Test 1 moves from dns/ to probe/ ✅ (50afd0e)

  • dns/availability.rs (1146 lines, test 1) → probe/dns_avail.rs; dns/ remains pure protocol (wire, udp, doh, dot, socks, resolve, types).
  • Removes the dns → probe and dns → classify edges.
  • Cost: 9 imports in the binary (render.rs, runner.rs) and dns/mod.rs:11.

P2.5 — detail from strings to a type ✅ (6ff72e5)

Today classification parses prose: i18n/details.rs:32,33,51 splits the detail on " at " and strips "KB", probe/whitelist.rs:86,225 checks detail.contains(DET_AT_KB_MARKER), and assembly goes through format!("{}{}{}{}", …, DET_AT_KB_MARKER, kb, DET_KB_SUFFIX) in probe/tcp16.rs:177,260,273,286 and probe/domains.rs:238,241,401,404. DET_* occurs in 276 lines, and classify/constants.rs:44-46 outright calls the marker part of the wire format.

  • Target: enum Detail in classify (TlsDrop, ReadTimeout { kb }, Tcp16Range { kb }, Spoof(…), CertIssue(…), DnsError(…), …) with code() for JSON and rendering through i18n::detail_text(Detail) — an exhaustive match instead of a runtime coverage test.
  • Effect: comparing variants instead of strings, a snake_case token in JSON, prose only in i18n; ALL_DET_DETAILS and i18n/details.rs shrink to a table of variants.
  • Risk: medium (it touches every error path). Verification: the gate + a live -t 2/3/4/5 with a --json diff against the P0 snapshot.

P2.6 — Typed --json ✅ (81bd6bc)

  • Today runner.rs assembles the output by hand — 14 sites of serde_json::json!. The core reports are already structs (DnsAvailReport, DomainStats, BurstReport, TelegramFullReport), DpiStatus is already Serialize (classify/types.rs:30-31).
  • Target: #[derive(Serialize)] on the reports + one top-level wrapper; status and detail go into JSON through serde.
  • Effect: the schema is described in one place; the class of bugs "the field is in the table but not in the JSON" disappears.

P2.7 — i18n split by language files ✅ (8724e7e, later moved into the binary)

i18n/mod.rs — 1739 lines, Messages — 212 fields × 4 blocks. Split into i18n/{mod,en,ru,zh,fa,details}.rs. The field parity test is kept: adding a language is one new file.

P3 — Split render.rs and menu.rs ✅ (c7660fb)

  • tui/backend.rs — VT/Win32 FFI, output_str, frame_home, modes (ascii, plain, has_vt).
  • tui/widgets.rs — panels and frames (panel_with, box_chars, asc, cell_color, status_color), width and wrapping (strip_ansi_len, wrap_ansi), frame_repaint.
  • tui/progress.rs — LiveProgress, Spinner, progress_line, Refresher.
  • views/* — along the already existing section markers // ─── Test N ─── (netinfo, dns, domains, tcp, whitelist, telegram, burst, summary).
  • menu.rs → tui/screens/{main,burst,legend,post_run}.rs + tui/input.rs (keyboard-layout normalisation and nav_key).
  • Hard rule: the width maths and frame_repaint stay in one module.
  • Also P3.5: remove the internal markup layer — 16 sites of [green]…[/] (render.rs:2238-2289) and the markup_to_ansi converter (render.rs:2362-2371): write SGR directly.
  • Verification: a live TUI in a pty (main menu and the test 6 screen) + the gate.

P4 — Split net/netinfo.rs ✅ (750e185)

  • net/http_client.rs — http_get_text, http_get_chain, http_get_once, request_once (needed both by public IP and by Cymru).
  • sysinfo/os.rs, sysinfo/bypass.rs — the Windows registry, adapters, route, get_system_dns, detect_bypass_tools, resolv.conf/getprop.
  • Breaks net → dns (net/netinfo.rs:21) and makes the graph acyclic.
  • Done (1aebf22): TCP_NODELAY is enabled on every dial through net::tcp::set_no_delay — DoH, DoT, SOCKS5, the GET in netinfo, the three Telegram paths and dial_tcp, so the rule from net/tcp.rs:22 ("это требование, а не оптимизация") holds for every dial, not for four out of ten.

P4.5 — Collapse the TLS factories ✅ (750e185)

net/tls.rs — nine pub fn create_*; probe/connector.rs — seven constructors (new_insecure, …_tls13, …_tls13_with, …_tls12, …_tls12_with, …_versioned_with, new_verifying). Target: one TlsProfile { fingerprint, version, alpn, verify } and one RustlsConnector::from(profile). Required tests: grease_version_leads_supported_versions, bundle_versions_match_their_ja3, bundle_versions_match_their_ja4.

P5 — i18n from the core into the binary ✅ (0dc0fa7)

Decision made. The order is mandatory: after P1 (otherwise the core still needs i18n for net/version.rs) and after P2.5/P2.6 (so that the detail prose does not have to be moved twice): first detail becomes a type with code(), then the texts move to dpi-detector/src/i18n/.

  • What moves: Messages (4 blocks), legend_sections*(), details.rs, fingerprint_label, fmt_speed/fmt_size, Messages::{config_warning, phase_text}.
  • What stays in the core: PhaseId/ProgressBlock (language-independent identifiers) and the detail constants; the i18n in the binary imports them — the dependency direction becomes one-way.
  • Along with the move, the project rules are amended (§1 of this document and the README): the entry point for a new string is the binary crate, not the core.
  • Verification: the four-block parity tests (test_messages_coverage, test_finglish_is_latin_only, i18n::details::tests) move together with the module; a live run of the menu and --legend in two languages (--lang en, --lang fa).

P6 — CI gate ✅ (ce546e3)

In .github/workflows/ there is currently only release.yml (a matrix of 12 targets, on tag). Add a check job on push/PR: cargo test --workspace, cargo clippy --workspace --all-targets, cargo build --locked. As a separate item (by decision) — comparing the vendored vendor/rustls with the current crates.io release.

P7 — Audit of unwrap()/expect() outside tests ✅ (750e185, breakdown below)

39 places in 9 files: profile/mod.rs 15, dns/availability.rs 8, probe/domains.rs 5, net/tls.rs 4, config.rs 3, and one each in render.rs/tcp16.rs/http.rs/fingerprint.rs. With panic = "abort" a panic kills the whole run: every case must either be justified by a comment (an invariant proven by construction) or turned into ?/a default.

P8 — TestSelection instead of a 9-bool tuple ✅ (ac53995)

selection_flags (main.rs:34-46) returns nine bools; a transposition in such a tuple compiles silently. Introduce a struct TestKind/bit set, with menu.rs and runner.rs as consumers.

P9 — Typed config (decided: not doing it)

The tolerance layer for "dirty" hand-written config.yml (numbers as strings, key case and nesting, unknown keys as warnings) stays: it is covered by tests, including parity with config.yml, and removing it would save ~300 lines at the cost of refusing to accept configs that work today. Decision made 2026-09-14: no typing.

5. Rejected (do not propose again)

Proposal Reason
A DiagnosticTask trait for all seven tests The tests have different signatures and reports by design (check_dns_availability(&AppConfig, Option<PhaseProgress>, usize) versus burst_targets(&AppConfig, &[BurstTarget], &BurstSettings, usize)), and the progress and cancellation seam already exists: PhaseProgress/ProgressTick/BlockTick and the language-independent PhaseId/ProgressBlock
Rename probe/ to engine/ and introduce a protocols/ layer A rearrangement with no boundary: probe/ holds six engines and three small primitives (connector.rs 91, http.rs 172, tls.rs 196)
Move probe/connector.rs into net/ The trait takes and returns DpiProbeStream, and net/ today knows nothing about classify (zero references) — the move would create a new bottom-up edge
Move probe/http.rs into protocols/ There are no consumers outside the probes, and the move would spread probe support across two layers
enum DiagnosticVerdict instead of DpiStatus DpiStatus is already an enum; it was detail that needed typing — done in P2.5 (classify::Detail)

Added 2026-09-27 by the QUIC review. Five proposals were examined and refused; each carries the reason so the next pass does not re-open it:

Proposal Reason
Move TlsFingerprint::sends_raw_extension / sig_algs out of net/fingerprint and next to their only consumer (probe/quic.rs) They answer a question about a shape's own data, which is what net/fingerprint owns — the same reason needs_pq, sends_ech and advertises_cert_compression live there. One consumer today does not make the data the consumer's.
Delete QuicCheck.elapsed as unused It has a consumer: crates/dpi-core/examples/quic_probe_once.rs prints it as elapsed: for the stand. "Unused in the product" is not unused.
Make domain_stats count the QUIC column The stats deliberately ignore it — a QUIC endpoint is absent for most domains, so folding it into the blocked count would turn "no HTTP/3 here" into a finding. The decision is written at the phase in probe/quic.rs.
Move the QUIC byte layer (net/quic.rs) into dns/ or probe/ It is net/'s layer by §3: it owns the wire, imports nothing from the crate, and both dns/ and probe/ would create a new edge.
Give the QUIC phase its own progress type instead of PhaseProgress/PhaseId Same refusal as the DiagnosticTask row above: the seam already exists, and PhaseId::DomainQuic was missing from stage_block() — a wiring gap, not a missing abstraction.

Added 2026-09-27 by the structure review. Five more were examined and refused:

Proposal Reason
Merge the three copies of the aioquic H3 client in scripts/quic/ The stand is a development harness outside the workspace, and the three files ask different questions (crosscheck, bracket, variants) while sharing decrypt.py for the low-level work. Merging them would couple experiments that are meant to disagree with each other.
Rename net/http.rs / net/http_client.rs so the names say which is which They are a real boundary — the fingerprinted probe sender versus the control-plane GET — and every call site of both would change to move no allocation. The http_client.rs entry in AGENTS.md's directory map is where the distinction is written down.
Regenerate scripts/quic/hosts.txt from domains.txt The stand needs a fixed host list so a re-run is comparable with the previous one; regenerating it would move the stand's baseline every time a target is added to test 2.
Strip the ☆ from the nine tcp16.json rows that carry it That is the author's data, and whether the marker means "this ASN needs a second look" is not for a review pass to decide. What the pass did remove is the Rust fallback that disagreed with those rows.
Remove the config → net edge in AppConfig::clamp The parse must happen before a run and the warning it raises is a load-time ConfigWarning; moving the check would change when an operator learns their tls_fingerprint is unknown. §3 names it as the one exception rather than pretending it is not there.

Added 2026-09-27 by the fingerprint review. The pass itself is in the commits (the named drop12/drop13 constants, the two byte-identical records turned into aliases, the narrowed accessors, the ECH counts, the two doc comments that contradicted their own records). Six proposals were examined and refused:

Proposal Reason
Deduplicate the identity-only sibling records (chrome133/chrome146, the Safari rows, edge101/107) with struct-update syntax Their whole point is a second identity over one TLS shape, and each record is read field by field by the table, the JA3/JA4 pins and --legend; a ..BASE spread would hide which fields a record actually names, and the sharing that pays (the big lists) is already named.
Merge HelloVariant::AddExtension into ExtBody(id, &[]) They are not equivalent: ExtBody replaces an entry (retain first) while AddExtension appends, so the merge either changes behaviour for an id already in the order or reproduces that difference inside one variant. The duplicate-order footgun is real and is recorded here instead.
Alias go.rs/tor.rs's once-written drop12/drop13 arrays to the Safari/Firefox constants they happen to equal Aliasing a once-written array couples two unrelated clients' records (go → safari) for no repetition saved. The repeated arrays — 8–12 copies per family — are named now; these two are written once.
Merge ja3.rs's and ja4.rs's parsing ja4.rs already reads ja3.rs's walkers; what remains is that each walks the record once, which is the price of two independent hash implementations — and a JA4 that disagrees with JA3 is a signal worth keeping.
Replace TlsShape::token with a derivation from label They differ exactly where the token is not the label (--legend prints the token); deriving it needs the same table the field already is, at the cost of a lookup per call.
Give the QUIC column its own ECH handling instead of inheriting the shape's GREASE ECH The column sends what the shape sends, and a live Chrome's QUIC hello carries extension 65037 with a GREASE body (measured: 0xfe0d in tp_dump.py's dump of its hello). Documenting and testing that coupling is a follow-up, not a divergence.

Added 2026-10-17 by the QUIC header work. One proposal was examined and refused; the decision it lost to puts four numbers on the wire, so it is recorded here:

Proposal Reason
Keep the Initial's header constants in probe/quic.rs (CID_LEN, the 1200-byte pad) and take only the ClientHello from the shape Those four numbers — the packet number, the destination and source connection-ID lengths, the pad — are what a censor reads off an Initial before it decrypts any of it, so they are the impersonated client's and not the probe's: Chromium sends no source connection ID, starts at packet number 1 and pads to 1250, Firefox sends a three-byte one and draws both numbers, Safari sends the eight-byte pair at the RFC's floor. They now live in TlsShape::quic (QuicShape, net/fingerprint/shapes/mod.rs) beside the hello; net/quic::client_initials takes the pad as an argument and clamps it to the §14.1 floor itself, and the probe still draws the connection-ID values at random — only their lengths are the profile's. Measured live: --fingerprint chrome146 (empty SCID, 1250 bytes) and firefox133 (drawn DCID, SCID 3) both read OK on Cloudflare's and Google's edges.

Superseded 2026-09-24. The first two rows above were acted on, and the second of them only partly as written:

  • The premise of the connector.rs row was already stale when it was read: net/tcp.rs and net/icmp_err.rs both use crate::classify::IcmpCode, so the edge it feared existed. connector.rs and http.rs now live in net/, which also retires the five doc comments in net/fingerprint/ that pointed up into probe/.
  • http.rs went to net/, not to the protocols/ layer that row rejects — that layer does not exist, and the objection ("no consumers outside the probes") is the reason it belongs beside them in net/ rather than above them.
  • probe/cymru.rs moved to dns/cymru.rs in the same pass. The phase log above records Cymru being moved into probe/ to break a net → dns cycle; nothing in net/ uses it now (probe/dns_avail.rs and the binary do), and it calls dns::query_doh_txt itself, so dns/ is the layer it can live in. The cycle stays broken only as long as net/ keeps its hands off dns/ — the rule AGENTS.md states.

6. How to verify each phase

cargo test --workspace
cargo clippy --workspace --all-targets      # exactly 0 warnings
  • One commit — one phase. Do not mix moves and edits in one commit: a move must read as a move.
  • Do not run cargo fmt: in the tree 48 of 54 files are CRLF, rustfmt writes only LF, cargo fmt --all --check complains about 39 files — a single run would rewrite and reformat almost the whole tree. Format by hand.
  • Live verification by surface: TUI — a run in a pty (menu and the test 6 screen); CLI — the release-local binary with --json; for output changes — a diff against the P0 snapshot.
  • Fast build for iterations: cargo build --profile release-local --target x86_64-pc-windows-msvc -p dpi-detector.
  • The release artifact is built only when it is really needed: RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target x86_64-pc-windows-msvc. dist/ is updated on an explicit request.