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.
28 KiB
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
ruzstdis 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;configandprofilesit beside that chain and the binary'si18nsits outside the core entirely. §3 names the one deliberateconfig → netedge. - Stage classification. Every TCP/TLS probe is wrapped in
DpiProbeStream; a reset or EOF after ClientHello isDpiStatus::TlsRst, a connect timeout isDpiStatus::SynDropped. - Badges and protocols are Latin-script in every language (
OK,BLOCKED,TLS RST,SNI,ClientHello); only headings, banners, menus and--legendare translated. - Machine output is language-independent.
--jsonnever follows--lang:statusanddetailare 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_repaintwithrender::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 throughnet/http_client.rsand the newTlsProfile, and on their own not one part compiles. - P4.5 did not shrink the file:
net/tls.rs273 → 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_signatureswas removed in P1.5: afterprobe/tls.rswas deleted nobody read it, and the signature list remains in git history until a consumer appears.Messagesslimmed 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/expectremain outside tests, each with a written reason why it is impossible (the TLS provider, a constant HTTP request, the fingerprint spec table,serde_yamlon an empty mapping, an example utility). - P4 decomposed differently from the plan: instead of
sysinfo/{os,bypass}.rs,net/public_ip.rsandnet/sysinfo/mod.rsalso 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.rsis a re-export facade overnet/http_client,net/public_ipandnet/sysinfo— a live one, with nine consumers in the binary — and the Cymru lookup that calledquery_doh_txtnow lives indns/cymru.rs(§5).dns → probe— gone with P2 (50afd0e):dns/availability.rsno longer exists, it isprobe/dns_avail.rs, andfake_ip_typeis imported fromprobe::domainsby the layer that owns it.dns → classify— not a cycle:classifyis the leaf of the diagram below, so this edge points down the layering; only an edge back up out ofdnscould close a loop.
Re-checked on the tree rather than read off the phase log:
- a search for
crate::dnsundercrates/dpi-core/src/net— no matches; - a search for
crate::probeundercrates/dpi-core/src/dns— no matches; - a search for
crate::+net/dns/probeundercrates/dpi-core/src/classify(brace-elided forms included) — no matches, so the leaf still imports nothing from the layers above it; dns/availability.rsis absent fromls crates/dpi-core/src/dns(cymru doh dot mod resolve socks types udp wire);- a search for
crate::probeand forcrate::netundercrates/dpi-core/src/config.rs— the first only inside#[cfg(test)], the second only on theclampline named above; - a search for
crate::renderandcrate::viewsundercrates/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
--jsonfor-t 1,-t 2 -d example.com,-t 5into 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
detailand 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
i18nat all (net/version.rs:1,160), and it also pulls innetinfo::http_get_text. - Cost: two imports in the binary (
main.rs:9,menu.rs:17) and an edit tonet/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.rsremoved entirely. - Remaining — lower to
pub(crate)or remove:net/version.rs:154version_badge(called only from its own test), 27 unused re-exports ofdns/mod.rs:10-17(exactly one is live through a re-export,parse_socks_proxy),net/tls.rscreate_insecure_dpi_tls_config_tls13/tls12,net/connector.rsnew_insecure_tls13/new_insecure_tls12/new_verifying,classify/stream.rsProbeStateandDpiProbeStream::{into_inner,get_ref,get_mut},dns/wire.rsQTYPE_NS/SOA/PTR/MX,net/cert_compression.rscovers(tests only),i18nLanguage::{code,name,is_rtl},profiledefault_resolvers/default_doh/blockpage_signatures(tests only),lib.rsaliasesPhaseSwitch/BlocksSwitch. - Protection:
unreachable_pubis enforced from the workspace lint table (Cargo.toml,[workspace.lints.rust]), whichdpi-coreopts into with[lints] workspace = true; work through the output so the surface does not creep again. The binary crate carries a dated localallowfor it until thepub→pub(crate)sweep overdpi-detectorlands — its modules are private, so the lint fires on everypubitem 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 → probeanddns → classifyedges. - Cost: 9 imports in the binary (
render.rs,runner.rs) anddns/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 Detailinclassify(TlsDrop,ReadTimeout { kb },Tcp16Range { kb },Spoof(…),CertIssue(…),DnsError(…), …) withcode()for JSON and rendering throughi18n::detail_text(Detail)— an exhaustivematchinstead of a runtime coverage test. - Effect: comparing variants instead of strings, a snake_case token in JSON, prose
only in i18n;
ALL_DET_DETAILSandi18n/details.rsshrink to a table of variants. - Risk: medium (it touches every error path). Verification: the gate + a live
-t 2/3/4/5with a--jsondiff against the P0 snapshot.
P2.6 — Typed --json ✅ (81bd6bc)
- Today
runner.rsassembles the output by hand — 14 sites ofserde_json::json!. The core reports are already structs (DnsAvailReport,DomainStats,BurstReport,TelegramFullReport),DpiStatusis alreadySerialize(classify/types.rs:30-31). - Target:
#[derive(Serialize)]on the reports + one top-level wrapper;statusanddetailgo 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 andnav_key).- Hard rule: the width maths and
frame_repaintstay in one module. - Also P3.5: remove the internal markup layer — 16 sites of
[green]…[/](render.rs:2238-2289) and themarkup_to_ansiconverter (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_NODELAYis enabled on every dial throughnet::tcp::set_no_delay— DoH, DoT, SOCKS5, the GET innetinfo, the three Telegram paths anddial_tcp, so the rule fromnet/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; thei18nin 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--legendin 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.rsrow was already stale when it was read:net/tcp.rsandnet/icmp_err.rsbothuse crate::classify::IcmpCode, so the edge it feared existed.connector.rsandhttp.rsnow live innet/, which also retires the five doc comments innet/fingerprint/that pointed up intoprobe/. http.rswent tonet/, not to theprotocols/layer that row rejects — that layer does not exist, and the objection ("no consumers outside the probes") is the reason it belongs beside them innet/rather than above them.probe/cymru.rsmoved todns/cymru.rsin the same pass. The phase log above records Cymru being moved intoprobe/to break anet → dnscycle; nothing innet/uses it now (probe/dns_avail.rsand the binary do), and it callsdns::query_doh_txtitself, sodns/is the layer it can live in. The cycle stays broken only as long asnet/keeps its hands offdns/— the ruleAGENTS.mdstates.
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 --checkcomplains 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-localbinary 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.