Skip to content

Latest commit

 

History

History
104 lines (81 loc) · 8.27 KB

File metadata and controls

104 lines (81 loc) · 8.27 KB

Trunks Code Review — Issue Tracker

Two Jon Gjengset-style reviews across the full Rust codebase (~5300 lines). Review #1 found 18 issues, fixed 11. Review #2 found 10 new issues. All 17 issues resolved: 14 ✅ fixed, 3 ⏭️ deferred (1 LOW + 2 MEDIUM not-a-bug/not-worth-fixing).

Status Legend

  • ⬜ Open
  • 🔧 In Progress
  • ✅ Fixed
  • ⏭️ Deferred (won't fix now)

Review #1 — Unfixed Issues

#1 ✅ MEDIUM — duration_as_nanos::serialize silently truncates u128 → u64

File: lib/src/hit.rs:189 Problem: duration.as_nanos() as u64 silently wraps for durations > ~584 years. Also used in metrics.rs for serialization of total, mean, duration, wait, and all percentile fields. Fix: Replaced as u64 with u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) to saturate. 1 test added.

#2 ✅ HIGH — Metrics::close panics if requests > u32::MAX

File: lib/src/metrics.rs:173 Problem: self.latencies.total / self.requests as u32 — the as u32 truncates, potentially to 0, causing divide-by-zero panic. Fix: Replaced with self.latencies.total.div_f64(self.requests as f64). Test added that confirms no panic with requests > u32::MAX.

#6 ✅ LOW — Attack struct has all pub fields, no builder

File: lib/src/attack.rs:30-43 Problem: All fields are pub, making the API fragile for library consumers. No builder or new() method. Fix: Added AttackBuilder with Attack::builder(name, client, pacer, targets) constructor. Required fields (name, client, pacer, targets) are positional; optional fields (duration, workers, max_workers, timeout, max_body, redirects, chunked, stop) have sensible defaults. All fields now private.

#8 ✅ LOW — Codec trait uses async_trait boxing in hot path

File: lib/src/hit.rs:37-41 Problem: #[async_trait] adds a Box::pin allocation on every encode/decode call. In the attack hot loop, this is per-request overhead. Fix: Removed the Codec trait entirely. JsonCodec, CsvCodec, MsgpackCodec now have inherent async fn encode/decode methods. The trait was never used for dynamic dispatch (dyn Codec), only to bring methods into scope. Zero boxing overhead.

#9 ✅ LOW — Targets enum generic over R even for Static variant

File: lib/src/target.rs:29-37 Problem: Targets<R: AsyncBufRead> forces a type parameter even for Static which doesn't use R. This bleeds into Attack<C, P, R>. Fix: Made TargetRead trait non-generic and object-safe (with Send bound). Targets::Lazy now stores Box<dyn TargetRead>. Erased R from Targets and Attack<C, P, R>Attack<C, P>. Manual Debug and Default impls added for Targets.

#12 ⏭️ LOW — Response headers collected into HashMap on every response

File: lib/src/attack.rs:308-316 Problem: Every non-error response allocates a HashMap<String, Vec<String>> of response headers. Resolution: Not worth fixing. Headers ARE used — serialized in every Hit output (JSON/CSV/msgpack). The HashMap allocation (~100-300 bytes) is negligible vs network I/O per request. Using HeaderMap directly would require custom serde impls for marginal gain.

#13 ⏭️ MEDIUM — targets.lock().await holds mutex across async decode

File: lib/src/attack.rs:161 Problem: targets.lock().await.decode().await holds the Mutex guard across the .decode().await point. In lazy mode, this serializes all target reads and blocks all workers. Resolution: Not a practical issue. Only the single pacer task calls decode; workers receive targets through a channel. The lock can't be split from decode since Lazy reader state requires exclusive access during the async read. Issue #23 fix (body cache) makes decode fast.


Review #2 — New Issues

#19 ✅ CRITICAL — Response body fully buffered before max_body truncation

File: lib/src/attack.rs:317-319 (fast path) and 392-394 (slow path) Problem: to_bytes(res.into_body()).await?.to_vec() reads the ENTIRE response body into memory, then truncates to max_body. A target returning gigabytes will OOM the attacker. Fix: Replaced with read_body() streaming helper that accumulates chunk-by-chunk up to max_body bytes, then discards the rest. Also added drain_body() for redirect drain paths. 6 tests added.

#20 ✅ HIGH — spawn_blocking per DNS query with custom resolvers

File: lib/src/resolver.rs:310 Problem: tokio::task::spawn_blocking(move || dns_resolve(...)) creates a new OS thread for every DNS resolution when using --resolvers. At high RPS this exhausts thread pool limits. Fix: Replaced dns_resolve with dns_resolve_async using tokio::net::UdpSocket and tokio::time::timeout. No more spawn_blocking in custom resolver path. 1 async integration test added with mock DNS server.

#21 ✅ HIGH — New UDP socket per DNS query

File: lib/src/resolver.rs:228 Problem: UdpSocket::bind("0.0.0.0:0") inside dns_resolve allocates a new ephemeral port and FD per query. Under load this exhausts port range and FD limits. Fix: Replaced blocking std::net::UdpSocket with async tokio::net::UdpSocket. Socket is now lightweight async I/O instead of blocking OS thread + FD. Further optimization to share a socket pool deferred — async sockets are cheap and DNS caching (TTL) already reduces query volume.

#22 ✅ HIGH — DNS Transaction ID not validated

File: lib/src/resolver.rs:239-251 Problem: The DNS response is parsed but the Transaction ID is never checked against the request's ID. Accepts spoofed DNS responses. Fix: build_dns_query now returns (Vec<u8>, u16) with the transaction ID. parse_dns_response takes expected_id: u16 and rejects mismatches. 2 tests added: ID validation and unique ID generation.

#23 ✅ HIGH — tokio::fs::read in decode_http hot loop

File: lib/src/target.rs:221 Problem: @body_path syntax calls tokio::fs::read(body_path).await inside decode_http, which runs on every call in lazy mode — filesystem I/O per request under the targets lock. Fix: Added body_cache: HashMap<String, Bytes> to TargetReaderInner. First @path read populates cache; subsequent reads for same path return cached Bytes::clone(). 1 test added verifying cache hit after file modification.

#24 ⏭️ MEDIUM — ConstantPacer integer division before multiply causes burst behavior

File: lib/src/pacer.rs:52 Problem: self.freq * (elapsed.as_nanos() / self.per.as_nanos()) — the integer division truncates to 0 for all t < per, then jumps. Resolution: Not a bug. The expected_hits check is only a fast-path optimization; the interval/delta calculation on lines 58-69 correctly handles sub-period pacing regardless. Matches vegeta's Go implementation. The pacing behavior is correct.

#25 ✅ MEDIUM — Msgpack decoder trusts untrusted length prefix

File: lib/src/hit.rs:174-175 Problem: let len = u32::from_be_bytes(len_buf) as usize; let mut data = vec![0u8; len]; — a corrupted or malicious input declaring len = 4GB causes immediate OOM. Fix: Added MAX_MSGPACK_FRAME = 64MB constant. Length prefix checked before allocation; returns error if exceeded. 1 test added.

#26 ✅ MEDIUM — Metrics::close is not idempotent

File: lib/src/metrics.rs:177 Problem: std::mem::take(&mut self.latencies.latencies) destroys the latency data on first call. A second close() call produces zero percentiles silently. Fix: Added closed: bool guard field. close() returns early if already closed. 1 test added.

#27 ✅ MEDIUM — status_codes map allocates String key per request

File: lib/src/metrics.rs:148-149 Problem: hit.code.to_string() allocates a new String for every request to use as the BTreeMap<String, u64> key. Fix: Changed to BTreeMap<u16, u64> with custom serialize_status_codes to maintain JSON compatibility. Zero allocations per add() call for status tracking.

#28 ✅ MEDIUM — Proxy CONNECT reads response byte-by-byte

File: lib/src/proxy.rs:233-247 Problem: stream.read_exact(&mut byte) in a loop — one syscall per byte to parse the CONNECT response headers. Fix: Replaced with 512-byte chunk reads using stream.read(&mut chunk). Scans accumulated buffer for \r\n\r\n terminator. Also added EOF detection.