rust-intel-cc 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +887 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +211 -0
- package/bin/install.js +119 -0
- package/commands/rust-intel-cc/audit.md +112 -0
- package/commands/rust-intel-cc/fix.md +137 -0
- package/commands/rust-intel-cc/plan.md +74 -0
- package/package.json +37 -0
- package/skill/SKILL.md +454 -0
- package/skill/async.md +329 -0
- package/skill/audit-project.workflow.js +283 -0
- package/skill/concurrency-and-state.md +186 -0
- package/skill/data-and-types.md +212 -0
- package/skill/deps-macros-ergonomics.md +127 -0
- package/skill/drop-and-raii.md +48 -0
- package/skill/lifetimes-and-api.md +111 -0
- package/skill/security.md +82 -0
- package/skill/semantics-and-conformance.md +127 -0
- package/skill/testing.md +112 -0
- package/skill/unsafe-and-ffi.md +106 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Rust Intel — Data, Types, Numerics & Iterators (serde, Eq/Hash, numeric, strings, allocation/complexity cost)
|
|
2
|
+
|
|
3
|
+
> Module of the **rust-intel** skill. Core — operating mode, blocking protocol, enforcement tiers, the trigger table, version pins, and the category→module map — lives in `SKILL.md`. This module holds the category bodies for §B6, §B16, §B20, §B26, §B27, §B28, §B29, §C4, §E2, §E3 — plus the **Substitution catalog** (Tier E appendix: pattern → cheaper representation, gated by §E6). Tier labels (🔴/🟡/🟢; A–F) and all cross-references are preserved verbatim.
|
|
4
|
+
> **Tiers in this module:** §B6 🟡 · §B16 🟡 · §B20 🟡 · §B26 🟡 (narrowing as — 🟢 except trust boundary) · §B27 🟡 · §B28 🟡 · §B29 🟡 · §C4 🟡 · §E2 🟡/🟢 · §E3 🟡/🟢. Derived from SKILL.md → Enforcement tiers (canonical).
|
|
5
|
+
> **Audit semantics:** 🔴 = report every occurrence; 🟡 = write-time discipline — report only load-bearing/non-obvious cases; 🟢 = clippy's, don't hand-report. Audit the *artifact* (a BANNED pattern present, a REQUIRED code artifact absent); process-REQUIREMENTs ("propose first", "ask the user") are not auditable findings.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## §B6. Pattern matching exhaustiveness drift
|
|
10
|
+
|
|
11
|
+
**The trap**: a `match` written today is exhaustive. After someone adds a new enum variant, it may silently become non-exhaustive only in `if let` form, or use a wildcard `_ => ...` that swallows the new case.
|
|
12
|
+
|
|
13
|
+
**REQUIRED**:
|
|
14
|
+
- For every `match` on an enum I do not own: assume the enum is `#[non_exhaustive]` and handle the fallback explicitly with a logged/typed error, not silent ignore.
|
|
15
|
+
- For every `match` on an enum I own: avoid wildcard arms unless I want adding-a-variant to compile silently. Use explicit arms.
|
|
16
|
+
- For every `if let Some(x) = ...` on a `Result` or option-chain that could grow new "interesting" failure modes, prefer `match` with explicit arms.
|
|
17
|
+
|
|
18
|
+
**BANNED**:
|
|
19
|
+
- `_ => unreachable!()` or `_ => panic!()` for enums where new variants could legitimately be added.
|
|
20
|
+
- `_ => Ok(())` swallowing an error case.
|
|
21
|
+
|
|
22
|
+
## §B16. Equality and hashing contracts
|
|
23
|
+
|
|
24
|
+
**The trap**: `derive`-ed `Eq`/`Hash` is correct by construction. The moment a manual `impl PartialEq` or manual `impl Hash` enters the type — to normalize case, ignore a field, hash-by-key-only — the `HashMap`/`HashSet` contract `a == b ⇒ hash(a) == hash(b)` can be quietly violated. Compiles, runs, passes a few unit tests, and silently *loses entries from the map* in production: insert returns `None` (saying "no previous"), get returns `None`, but `len()` keeps incrementing — duplicate keys living at different hash buckets. Mirror trap on the ordering side: manual `Ord` that is not a *total* order corrupts `BTreeMap` ordering and `<[T]>::sort` (the sort assumes total order; if the relation is not total, the sort can produce arbitrary output, and `BTreeMap` invariants silently rot).
|
|
25
|
+
|
|
26
|
+
**BANNED**:
|
|
27
|
+
- Manual `impl PartialEq` whose result differs from `derive(PartialEq)` **on a type that also implements/derives `Hash` or is used as a `HashMap`/`HashSet` key**, without a corresponding manual `impl Hash` that matches. (A manual `PartialEq` on a type that is never hashed is sound — the contract only binds when `Hash` exists.)
|
|
28
|
+
- Manual `impl PartialOrd` without `impl Ord` for a type used as a key in `BTreeMap` / `BTreeSet` or as input to `.sort()` / `.sort_by()`.
|
|
29
|
+
- `sort_unstable` / `sort_unstable_by` / `sort_unstable_by_key` when the relative order of equal elements matters. "Unstable" means equal elements may be reordered, so a multi-key sort (sort by B over data already sorted by A) silently loses the secondary order. Use the stable `sort` / `sort_by_key` when the tie-break order is load-bearing; `sort_unstable` only when equal elements are genuinely indistinguishable or their order is irrelevant.
|
|
30
|
+
- `f64` / `f32` fields on a type that is later used as a `HashMap` or `BTreeMap` key, unless wrapped in `ordered_float::NotNan` / `ordered_float::OrderedFloat`. NaN breaks reflexivity (`NaN != NaN`), which breaks `Eq`'s contract; floats also have no total order in `PartialOrd` (NaN is unordered).
|
|
31
|
+
- Reaching for `f64::to_bits()` as a "trick" to hash a float — this works for bit-equal floats but splits `-0.0` and `+0.0` into two distinct keys (different bit patterns, yet `==` says equal — breaking the `a == b ⇒ hash(a) == hash(b)` contract) and treats NaN as a key (every NaN bit pattern is its own key, which is almost never what the caller wants).
|
|
32
|
+
- For a `HashMap`/`HashSet` whose **keys come from untrusted input** (request bodies, headers, parsed external data), replacing the default `RandomState` hasher (SipHash-1-3, seeded with per-process random state) with a fast hasher — `FxHashMap`/`rustc-hash` or `fnv` (not keyed at all, so always floodable on untrusted keys), or `ahash`/`foldhash`/`hashbrown` **configured with a fixed seed** (default-random `ahash`/`foldhash` are themselves DoS-resistant and fine for untrusted keys) — reintroduces **HashDoS**: an attacker who knows the (fixed or absent) seed forges keys that all collide into one bucket, degrading lookups to O(n) and burning CPU. std's own docs warn about exactly this. Use the default `RandomState` for untrusted keys; reserve the fast fixed-seed hashers for internal keys you control (enum tags, small integers, interned ids).
|
|
33
|
+
- **Regex engine choice on untrusted input — ReDoS sibling of HashDoS (CWE-1333).** The `regex` crate is linear by construction (RE2-style NFA, no backtracking — which is *why* it does not support lookaround or backreferences). When an LLM reaches for lookaround/backreferences, it switches engines — `fancy-regex`, `onig`, `pcre2` — which restore backtracking and with it *catastrophic backtracking*: an adversarial input (or an adversarially-chosen pattern) blows up to exponential time and pins a worker. Same shape as HashDoS, different primitive. BANNED: matching untrusted input with a backtracking-engine regex (`fancy-regex`/`onig`/`pcre2`) without an enforced input-size cap **and** a hard match timeout; an attacker-controlled *pattern* on any engine without those guards. REQUIRED for untrusted input: keep it on the `regex` crate (linear in the haystack for a fixed pattern); if a feature only a backtracking engine provides is truly needed, size-cap the input and wrap the match in a hard timeout (e.g. `std::thread`+channel, or an engine-native timeout). REQUIRED for an untrusted *pattern* — **even on `regex`**: bound the compiled program with `RegexBuilder::size_limit` (and `dfa_size_limit`) and cap the pattern length, because stacked counted repetition (`a{5}{5}{5}…`) blows up program size on the linear engine too; the linear-time-on-the-haystack guarantee does not cover an adversarial *pattern*. Compile patterns once (§E5) — separate concern, same code.
|
|
34
|
+
- Sorting floats with `v.sort_by(|a, b| a.partial_cmp(b).unwrap())` **panics** the moment a `NaN` is present (`partial_cmp` returns `None`). Use `v.sort_by(f64::total_cmp)` (a total order; `NaN` sorts to one end). The same `partial_cmp().unwrap()` trap hits `min`/`max` over floats. (If the values are provably non-`NaN` — validated, or `NotNan`/`OrderedFloat`/`Duration` — `partial_cmp().unwrap()` is not a bug, though `total_cmp` is still the cheaper default.)
|
|
35
|
+
- A comparator passed to `sort_by`/`sort_unstable_by` must be a consistent total order (strict weak ordering). An inconsistent comparator (e.g. one that flips direction based on external state) **may** make modern Rust's sort panic, and in any case yields an unspecified order (never UB) instead of silently scrambling — it only passed tests on small inputs by luck.
|
|
36
|
+
|
|
37
|
+
**REQUIRED**:
|
|
38
|
+
- If you customize `PartialEq`, customize `Hash` to match: `a == b ⇒ hash(a) == hash(b)`. Write the proof in a comment on the `impl Hash` block.
|
|
39
|
+
- For float keys, use `ordered_float::NotNan<f64>` (excludes NaN at construction) or normalize before hashing into a canonical form.
|
|
40
|
+
- `Ord` requires a *total* order: antisymmetric, transitive, total. `PartialOrd` does not. Before writing `impl Ord`, prove totality for your type — including edge cases (empty, all-equal, mixed signs for numerics).
|
|
41
|
+
- Flag a manual `impl PartialEq` / `impl Hash` / `impl Ord` inline (at write time) only when its contract is **non-trivial** — case/whitespace normalization, an ignored or derived field, a partial order, or any logic that can diverge from `derive`. A straightforward total `impl Ord` (or `PartialEq`) that simply compares one field or delegates to the fields in order needs no flag.
|
|
42
|
+
|
|
43
|
+
## §B20. `serde` field-presence vs null vs default
|
|
44
|
+
|
|
45
|
+
**The trap**: `Option<T>` with `#[serde(default)]` deserializes both `{ "field": null }` and `{}` (field absent) to `None`. For protocols where "absent" and "explicitly null" carry different semantics (HTTP PATCH, JSON-Merge-Patch, "preserve this field" vs "clear this field"), this collapse is a silent semantic bug — the code compiles, deserializes successfully, and propagates the wrong intent downstream. Adjacent traps: `#[serde(untagged)]` enums silently pick the first matching variant when two variants accept overlapping structural shapes; `#[serde(rename = "...")]` typos pass `cargo build` and corrupt the wire format.
|
|
46
|
+
|
|
47
|
+
**BANNED**:
|
|
48
|
+
- `Option<T>` field with `#[serde(default)]` in any API that must distinguish "field absent" from "field present as null" — both deserialize to `None` and the caller can no longer tell which the client sent.
|
|
49
|
+
- `#[serde(untagged)]` enum where two variants accept overlapping structural shapes — the first matching variant wins, silently, on inputs the API author did not anticipate. (Tag with `#[serde(tag = "type")]` instead, or write a custom `Deserialize` that proves disjointness.)
|
|
50
|
+
- `#[serde(rename = "wire_name")]` without a round-trip test (encode → decode equality on a representative sample of values).
|
|
51
|
+
- Trusting `#[serde(deny_unknown_fields)]` to catch typos — it catches unknown *incoming* fields, not typos in the struct field names that are being serialized.
|
|
52
|
+
- Deserializing a large integer (snowflake ID, nanosecond timestamp, `u64` > 2^53) into an `f64` field, or reading it via `serde_json::Value::as_f64()` — `f64` has only 53 bits of integer mantissa, so values above 2^53 silently lose precision (IDs collapse to neighbors). Use `u64`/`i64` typed fields, or `serde_json::Value::as_u64()`, and prefer `arbitrary_precision` only when you control both ends.
|
|
53
|
+
- Combining `#[serde(flatten)]` with `#[serde(deny_unknown_fields)]`: the two are documented as incompatible, and the failure is silent — `deny_unknown_fields` simply **stops rejecting** unknown fields (no error, unknown keys are accepted) once a sibling field is flattened. Separately, `flatten` routes the flattened fields through serde's internal `Content` buffer, which **cannot represent `u128`/`i128`** (deserialization fails with "u128 is not supported") and mishandles non-string map keys — fields that deserialize fine directly break the moment they sit behind a `flatten`. Don't pair the two attributes; if you need both behaviors, write a custom `Deserialize` (intercepting `MapAccess`) instead.
|
|
54
|
+
|
|
55
|
+
**REQUIRED**:
|
|
56
|
+
- For three-state semantics (absent / null / value), use `Option<Option<T>>` with `#[serde(default, deserialize_with = "double_option")]`, or a custom enum (`enum FieldUpdate<T> { Absent, Null, Set(T) }`) with an explicit `Deserialize` impl. Document the chosen scheme on the field.
|
|
57
|
+
- For `#[serde(untagged)]`, prove disjointness of variant shapes (no two variants accept the same JSON shape) or add a discriminator tag.
|
|
58
|
+
- Round-trip every (de)serialization with a property test (`proptest` or `quickcheck`) — encode arbitrary value, decode, assert equal.
|
|
59
|
+
- Flag each `#[serde(untagged)]`, `#[serde(rename = "...")]`, and `#[serde(default)]` on `Option<T>` inline (at write time).
|
|
60
|
+
|
|
61
|
+
## §B26. Lossy numeric conversions and integer overflow
|
|
62
|
+
|
|
63
|
+
**The trap**: `as`-casts between numeric types silently truncate, wrap, or saturate — no panic, no warning by default (`clippy::cast_possible_truncation` is pedantic, off by default, so the LLM never sees it). It compiles every time, tests on small numbers are green, and it breaks on large IDs/offsets/lengths in production. The same blind spot covers plain integer arithmetic: a bare `+`/`-`/`*` that overflows **panics in debug but silently wraps in release** (`overflow-checks` is off by default in the release profile), so the profile you test in and the profile you ship in disagree — and a `/`/`%` by zero or an out-of-range index panics in both.
|
|
64
|
+
|
|
65
|
+
**BANNED**:
|
|
66
|
+
- `as` for narrowing or sign-changing integer casts without a proven range: `u64 as u32`, `i64 as i32`, `usize as u32`, `i32 as u8`, `value.len() as u32`. The high bits are silently dropped; on a `>4 GiB` / `>4 billion` collection, `len() as u32` yields garbage.
|
|
67
|
+
- Assuming `usize as u64` or `u32 as usize` is always lossless — `usize` is 32-bit on wasm32 and other 32-bit targets, so `u64 as usize` truncates there.
|
|
68
|
+
- Treating `f as iN` / `f as uN` as wrapping or UB. Since Rust 1.45 it is **saturating**: `300.0_f32 as u8 == 255`, `-1.0_f32 as u8 == 0`, `NaN as i32 == 0`, `1e30 as i32 == i32::MAX`. Code written against pre-1.45 / C semantics gets a silently saturated value instead of the expected wraparound or error.
|
|
69
|
+
- Bare `+` / `-` / `*` / `pow` / `Iterator::sum` / `product` on integers that **come from untrusted input, grow unbounded, or accumulate monotonically over the process lifetime** (counters, offsets, lengths, balances, running totals) without `checked_*` / `saturating_*` / `wrapping_*`. This does **not** mean every arithmetic expression: routine bounded locals (`i + 1` in a loop over a known-small range, `(lo + hi) / 2` on in-range indices, arithmetic on values you just proved fit) are fine and should not be flagged. The target is the value that can realistically reach the type's edge. In **debug** an overflow panics (`attempt to add with overflow`); in **release** — where `overflow-checks = false` by default — it **silently wraps** (two's-complement). `cargo test` runs the debug profile and stays green; the release binary wraps a counter/offset/size through zero in production. This is a classic and easily-missed debug-vs-release divergence: the profile you test in and the profile you ship in disagree, and no lint catches it by default (`clippy::arithmetic_side_effects` is in the **`restriction`** group (not `pedantic`), off by default — so unlike the lossy-cast lint, even `-W clippy::pedantic` will not surface integer overflow; you must enable it explicitly).
|
|
70
|
+
- `a / b` or `a % b` on integers without proving `b != 0` — both panic in **debug and release** on a zero divisor; with `b` from untrusted input this is a clean remote DoS panic. (Note also: integer `%` truncates toward zero, so `-7 % 3 == -1`, not `2` — a surprise if you expect Python-style modulo.)
|
|
71
|
+
- `v[i]` / `&slice[a..b]` / `slice.split_at(i)` with an index derived from untrusted input — panics on out-of-bounds (the slice/integer mirror of §B28's string-boundary panic).
|
|
72
|
+
- `debug_assert!` / `debug_assert_eq!` are **compiled out in release builds** (the same `cfg(debug_assertions)` axis as overflow checks). An invariant or security check that must hold in production belongs in `assert!`, not `debug_assert!`; reserve `debug_assert!` for expensive checks whose failure is non-critical. (Converse trap: `dbg!` is **not** stripped — it evaluates and prints to stderr in release too, so a forgotten `dbg!` leaks into production output.)
|
|
73
|
+
|
|
74
|
+
**REQUIRED**:
|
|
75
|
+
- For narrowing conversions, use `u32::try_from(x)?` (or `TryFrom` / `try_into`) and handle the range `Err`. Keep `as` only for widening (`u8 as u64`) or explicitly-truncating-by-design casts with a `// truncation intentional: <reason>` comment.
|
|
76
|
+
- For float→int with range control, do an explicit check (`if x.is_finite() && x >= 0.0 && x <= u8::MAX as f32`) before the `as`; do not rely on saturation as your error handling.
|
|
77
|
+
- **Primary — make debug and release agree:** set `overflow-checks = true` in the release profile (`[profile.release]`). This is the highest-leverage fix for a binary you control: it turns every overflow into a panic in *both* profiles, so the profile you test in and the profile you ship in no longer disagree, without auditing every `+`. **Caveat:** it is a *global* runtime cost (≈5–15%+ on arithmetic-heavy code, and it blocks autovectorization of the checked operations). For a **numeric hot-path binary** (codecs, DSP, simulation, tight numeric kernels) prefer point `checked_*` at the few sites where overflow is actually reachable over the global flag — the §C4 "profile first" principle applies. Note this is a **binary-crate** lever: a *library* does not own its consumer's `[profile.release]`, so a library should protect long-lived or untrusted arithmetic with per-site `checked_*` (the Secondary rule below) rather than assume the global flag is set.
|
|
78
|
+
- **Secondary — explicit per-site handling**, for (a) values arriving from untrusted input at a trust boundary, (b) any site where wraparound must be caught as a *typed error* rather than a panic, and (c) any value that accumulates monotonically over the process lifetime (long-lived counters, offsets, running totals) when `overflow-checks = true` is not guaranteed in the project's release profile — i.e. don't rely on the global flag being set if you don't control the build profile: `checked_add`/`checked_mul`/… returning `Option` (handle `None` as a real error), `saturating_*` where clamping is the correct semantics, or `wrapping_*` **only** where wraparound is intended, with a `// wrapping intentional: <reason>` comment. This case (c) is what keeps a real long-lived counter protected by default — the over-flagging guard above (routine bounded `i + 1`) still applies; the target is the value that genuinely accumulates toward the type's edge.
|
|
79
|
+
- For division/indexing on untrusted input: `checked_div` / `checked_rem`, and `slice.get(i)` / `slice.get(a..b)` (returns `Option`) instead of the panicking `[]`.
|
|
80
|
+
- Narrowing `as` casts are a 🟢-tier item (delegated to `clippy::cast_possible_truncation` under `-W clippy::pedantic`, in the Post-flight command) — do not hand-surface them; this is the backing rule clippy enforces. **Exception (mirrors the 🟢-tier caveat):** a narrowing cast *on a trust boundary* — `len() as u32`, or any cast applied to untrusted/network input — is surfaced even with clippy/pedantic off, because there the truncation is a correctness/security defect, not a lint nit.
|
|
81
|
+
- `saturating_sub` on a `usize` length/cursor (`len - cursor`, `end - start`, `remaining - n`) to "avoid the underflow panic" — it does not fix the bug, it hides it. When the subtrahend exceeds the minuend (a cursor past the end, an off-by-one), `saturating_sub` quietly yields `0`, so the loop terminates early or the slice comes back empty with no signal. If `cursor > len` is genuinely impossible, prove it and use plain `-` (let it panic on the violated invariant); if it is possible, it is a *logic error* to handle explicitly (`checked_sub` → `None` as a real error path), not to clamp to zero.
|
|
82
|
+
|
|
83
|
+
## §B27. Wall-clock vs monotonic time
|
|
84
|
+
|
|
85
|
+
**The trap**: measuring durations and timeouts with a wall-clock that is not monotonic — NTP correction, manual clock changes, and DST produce a negative or jumping "duration". It compiles, the test over a few seconds on a stable clock is green, and it breaks days later or whenever the user's clock shifts.
|
|
86
|
+
|
|
87
|
+
**BANNED**:
|
|
88
|
+
- `SystemTime::now()` / `chrono::Utc::now()` / `std::time::SystemTime` to measure intervals, durations, timeouts, or benchmarks. The wall-clock can jump backward or forward between two readings.
|
|
89
|
+
- `.elapsed().unwrap()` or `.duration_since(earlier).unwrap()` on a `SystemTime` — both return a `Result` precisely because the clock can go backward; `.unwrap()` panics in production on an NTP step.
|
|
90
|
+
- `Duration` / `Instant` arithmetic that can overflow (`instant + very_large_duration`, `d1 + d2` on untrusted inputs) without a guarded variant. `Duration` has both `checked_add` and `saturating_add`; `Instant` has `checked_add` and `saturating_duration_since` (but **no** `saturating_add` on stable) — use those rather than bare `+`.
|
|
91
|
+
|
|
92
|
+
**REQUIRED**:
|
|
93
|
+
- `Instant::now()` for every duration, deadline, timeout, and benchmark — it is monotonic by contract. Use `SystemTime` only for absolute wall-clock stamps (logs, "created at") that must be serialized or displayed.
|
|
94
|
+
- Handle the `Err` from `SystemTime::duration_since` / `elapsed`, or use `Instant::saturating_duration_since`.
|
|
95
|
+
|
|
96
|
+
## §B28. UTF-8 and string-boundary hazards
|
|
97
|
+
|
|
98
|
+
**The trap**: string operations that are correct on ASCII and panic or corrupt on non-ASCII. Tests on `"hello"` are always green; the panic is deterministic on the first accented name, emoji, Cyrillic, or CJK character in production.
|
|
99
|
+
|
|
100
|
+
**BANNED**:
|
|
101
|
+
- `&s[a..b]` with computed indices without checking `s.is_char_boundary(_)` — it panics if an index lands inside a multi-byte UTF-8 character (`&"café"[0..4]` panics: 4 bytes, but the boundary is inside `é`).
|
|
102
|
+
- Conflating `s.len()` (a count of **bytes**) with a count of characters: `s.len()` for "take the first N characters", for display width, or for limits. `"café".len() == 5`, not 4.
|
|
103
|
+
- `to_lowercase()` / `to_uppercase()` for comparing protocol/ASCII tokens — these are full Unicode transformations and can change length (`ß` → `SS` under `to_uppercase`; Turkish `İ` → `i̇` under `to_lowercase`). For ASCII protocols use `eq_ignore_ascii_case` / `to_ascii_lowercase`.
|
|
104
|
+
|
|
105
|
+
**REQUIRED**:
|
|
106
|
+
- `s.get(a..b)` (returns `Option<&str>`, never panics) instead of `&s[a..b]` for computed bounds; `char_indices()` for iteration with byte positions; `chars().take(n)` for "the first N characters".
|
|
107
|
+
- For correct grapheme-boundary handling (emoji clusters, combining characters) use the `unicode-segmentation` crate (`graphemes(true)`); `chars()` alone splits on code points, not graphemes.
|
|
108
|
+
- `eq_ignore_ascii_case` for protocol strings; full Unicode case-folding only for user-facing display text.
|
|
109
|
+
|
|
110
|
+
## §B29. Iterator and slice adapter traps
|
|
111
|
+
|
|
112
|
+
**The trap**: the most common surface in LLM-generated Rust is also where several `std` adapters have silent, non-obvious semantics. The code compiles, `clippy` is quiet, and tests on equal-length / small / sorted inputs are green — then production data hits an edge the adapter handles differently than the LLM assumed.
|
|
113
|
+
|
|
114
|
+
**Specifically dangerous patterns**:
|
|
115
|
+
- **`zip` silently truncates to the shorter side.** `a.iter().zip(b.iter())` yields `min(a.len(), b.len())` pairs — the tail of the longer side is dropped with no error. When the two sequences are *expected* to be equal length (rows and headers, keys and values), a length mismatch becomes silent data loss, not a panic. Check lengths first, or use `itertools::zip_eq` (which panics on mismatch) when equal length is an invariant.
|
|
116
|
+
- **`Vec::dedup` only removes *consecutive* duplicates.** On an unsorted vector it does **not** produce a set: `[1,2,1,1,3,3,2]` dedups to `[1,2,1,3,2]`. For set semantics, `sort` first (then `dedup`) or collect into a `HashSet`/`BTreeSet`.
|
|
117
|
+
- **`chunks(0)`, `windows(0)`, `step_by(0)` panic.** A zero chunk/window/step size is a runtime panic, not an empty iterator. When the size comes from config or untrusted input, this is a remote panic / DoS.
|
|
118
|
+
- **`collect` into a `HashMap`/`HashSet` silently overwrites duplicate keys (last wins).** `pairs.into_iter().collect::<HashMap<_,_>>()` keeps only the last value per key and the resulting `len` is smaller than the input — silent loss when the input was supposed to be unique.
|
|
119
|
+
|
|
120
|
+
**REQUIRED**:
|
|
121
|
+
- When two sequences must be the same length, assert it (or use `zip_eq`) instead of relying on `zip` to line them up.
|
|
122
|
+
- Treat any chunk/window/step size derived from input as untrusted: guard `> 0` before calling.
|
|
123
|
+
- When collecting key/value pairs that must be unique, verify uniqueness rather than letting `collect` coalesce silently.
|
|
124
|
+
|
|
125
|
+
**BANNED**:
|
|
126
|
+
- `Vec::dedup` where adjacency of duplicates is not a proven invariant — flag a `.dedup()` with no `sort`/grouping visibly preceding it (it removes only *consecutive* duplicates, so on unsorted data it is not set-deduplication).
|
|
127
|
+
- A chunk/window/step size flowing from config or the network into `chunks` / `windows` / `step_by` without a `> 0` guard.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## §C4. Iterator and allocation discipline
|
|
132
|
+
|
|
133
|
+
**The trap**: unnecessary `clone()` on `Copy` types, materializing collections mid-chain with `collect::<Vec<_>>()` only to iterate again, `format!` in hot paths, treating `String` as the default string type everywhere.
|
|
134
|
+
|
|
135
|
+
**REQUIRED**:
|
|
136
|
+
- Profile before defending these on micro grounds — this "profile first" caveat is about *micro-costs* (an extra allocation, a `format!`, a redundant `clone`). It does **not** apply to the algorithmic-complexity items in BANNED below: an accidental O(n²) (`remove(0)`/`contains` in a loop) is a defect to fix on sight, not a micro-optimization to defer to a profiler. As defaults:
|
|
137
|
+
- Prefer `&str` and `&[T]` in function signatures over `String` and `Vec<T>`.
|
|
138
|
+
- Iterator chains stay lazy: avoid intermediate `.collect()` unless the next stage requires materialization.
|
|
139
|
+
- For hot paths, write to a `&mut impl io::Write` or `&mut String` via `write!`/`writeln!` rather than allocating with `format!`.
|
|
140
|
+
- `clone()` is fine when needed; surface it in the summary so the user can question it.
|
|
141
|
+
|
|
142
|
+
**BANNED**:
|
|
143
|
+
- `Vec::remove(0)` / `Vec::insert(0, _)` in a loop (each is O(n) — it shifts the whole tail), turning an O(n) pass into O(n²); likewise `Vec::contains` inside a loop is O(n²). Tests on small N pass; production degrades to seconds/minutes at scale. Use `VecDeque` for FIFO (O(1) front ops), `swap_remove` when order doesn't matter, or a `HashSet` / `retain` instead of repeated `contains`.
|
|
144
|
+
- `{:?}` (Debug) on `&[u8]` / `Vec<u8>` for hashes, checksums, IDs, or wire frames — it prints a decimal array `[222, 173, 190, 239]`, not hex. Use `hex::encode` (or a `LowerHex` newtype) for byte diagnostics. (For *secret* bytes, see §B12 — don't log them at all.)
|
|
145
|
+
- Treating a single `io::Read::read(&mut buf)` as if it fills `buf`, or a single `Write::write(data)` as if it writes all of `data`. Both may return `Ok(n)` with `n < len` even without EOF (sockets, pipes, large buffers). The code compiles, tests pass on small local buffers where one call happens to transfer everything, and production truncates or splices messages under load / over the network. Use `read_exact` / `write_all` / `read_to_end`, or loop until the count is satisfied; reserve bare `read`/`write` for code that genuinely handles short transfers.
|
|
146
|
+
- A `BufWriter`/`BufReader` flushes on drop, but the implicit flush in `Drop` **discards any `io::Result`**. On a failing writer (disk full, broken pipe, closed socket) the last buffered bytes are lost with no error surfaced. Call `.flush()?` explicitly before the writer is dropped when the data must be durable.
|
|
147
|
+
|
|
148
|
+
## §E2. Allocation that need not happen — *Cheap once, ruinous in a loop.*
|
|
149
|
+
|
|
150
|
+
- **Where it shows up**: reflexive `.clone()`/`.to_vec()`/`.to_string()` to dodge a borrow (§C5); an intermediate `.collect::<Vec<_>>()` only to iterate once; `Vec`/`String` grown by `push` in a loop with no `with_capacity` when the size is known; `format!` where `write!`/`Display`/`push_str` would write in place; returning owned `Vec`/`String` where `impl Iterator`/`&[T]`/`Cow<'_, str>` (§B1b) would let the caller decide; `Box`/`Arc` that buys nothing; a large struct passed by value where `&T` suffices.
|
|
151
|
+
- **The cheaper move**: borrow don't clone; take `&str`/`&[T]`, return `Cow` when ownership is conditional; pre-size with `with_capacity`/`reserve`; stream with iterators instead of materializing; reuse a scratch buffer (`clear()` + refill) across iterations and calls; `bytes::Bytes` for shared/zero-copy network buffers.
|
|
152
|
+
- **Leave it when**: one-shot on a cold path, the clone is of a `Copy`/tiny type, or removing it tangles lifetimes for no measured gain. `clippy::perf` flags the obvious cases (`inefficient_to_string`, `useless_vec`); `redundant_clone`/`needless_collect` live in `nursery` (allow-by-default) and need an explicit `-W` — the Post-flight command enables `-W clippy::redundant_clone`.
|
|
153
|
+
- 🟢 + 🟡. Cross: §C5, §B1b. For the full lookup table of representation swaps, see the **Substitution catalog** below.
|
|
154
|
+
|
|
155
|
+
## §E3. Complexity that compounds — *An O(n²) invisible at n=10 is an outage at n=10⁴.*
|
|
156
|
+
|
|
157
|
+
- **Where it shows up**: accidental quadratic — `.contains()`/`.position()`/`Vec::remove(0)`/`insert(0, _)` inside a loop (§C4); a nested-loop join that re-scans the inner collection per outer element; rebuilding or re-sorting a collection every iteration. The wrong container for the access pattern: `Vec` used for membership, front-insertion, or keyed lookup.
|
|
158
|
+
- **The cheaper move**: hoist the inner collection into a `HashSet`/`HashMap` once, then O(1) lookup; `VecDeque` for front/back queues; `swap_remove` when order is free; `SmallVec`/`ArrayVec` for almost-always-tiny collections; `BTreeMap` for ordered iteration; a `match` or fixed array (or `phf`) for tiny static key sets; sort once, not per iteration.
|
|
159
|
+
- **Leave it when**: n is provably small and bounded (a 3-element config), or the path is cold. Algorithmic complexity is the one performance class worth fixing without a profiler — unlike micro-allocation, it does not wait for load to hurt.
|
|
160
|
+
- 🟡 (escalate to surface on a per-request path). Cross: §C4. Verify any new crate before adding — §A1. For the full lookup table of representation swaps, see the **Substitution catalog** below.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Substitution catalog — a cheaper representation for the same job (Tier E appendix)
|
|
165
|
+
|
|
166
|
+
A write-time lookup table for §E2/§E3/§E4: when the code contains the pattern in the left column, *propose* the right column — with the gate in the third. This is a catalog, not a mandate: every row is 🟡 under §E6's measure-first law. Propose a swap when the path is hot / per-request or the change is free; never rewrite working code for an unmeasured win, and never trade an API's clarity for a micro-gain. Any row introducing a new crate goes through §A1 verification first. Audit semantics: rows here are suggestions, not findings — report one only when it coincides with a genuine §E2/§E3/§E4 hazard on a hot path.
|
|
167
|
+
|
|
168
|
+
**Ownership & allocation** (§E2's domain):
|
|
169
|
+
|
|
170
|
+
| You wrote | Consider | Gate — when it pays / when to leave it |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `.clone()` / `.to_string()` / `.to_vec()` to satisfy the borrow checker | `&T` / `&str` / `&[T]`; `Cow<'_, str>` when ownership is conditional | Always worth a look (§C5); leave when the type is `Copy`/tiny or the path is cold |
|
|
173
|
+
| Cloning a large immutable value into many tasks/threads | `Arc<T>` (also `Arc<str>`, `Arc<[T]>`) — clone becomes a refcount bump | Value is immutable after construction; if it mutates, that's §A2/§E4 territory, not this row |
|
|
174
|
+
| Copying byte buffers between pipeline stages | `bytes::Bytes` / `BytesMut` — refcounted zero-copy slicing | Multi-stage byte pipelines (network, framing); overkill for a one-shot read |
|
|
175
|
+
| Millions of short heap `String`s (ids, tags, tokens) | `compact_str` / `smol_str` — inline small-string optimization | Profile shows string-alloc churn; new dep → §A1 |
|
|
176
|
+
| A `Vec<T>` field that almost always holds ≤ N small items | `smallvec::SmallVec<[T; N]>` — inline until spill; `arrayvec` for strictly stack-only; `tinyvec` when a 100%-safe dep matters | Hot, allocation-dominated, N genuinely small; otherwise it just bloats the struct and adds a spill branch |
|
|
177
|
+
| `format!` in a hot loop (map keys, log lines, numbers) | `write!` into a reused buffer; `itoa`/`ryu` for hot int/float → string | Hot serialization paths; keep `format!` everywhere cold — readability wins |
|
|
178
|
+
|
|
179
|
+
**Lookup & complexity** (§E3's domain):
|
|
180
|
+
|
|
181
|
+
| You wrote | Consider | Gate — when it pays / when to leave it |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `vec.contains(x)` / `.position()` inside a loop | Hoist into a `HashSet`/`HashMap` once → O(1) per probe | n can grow; leave for tiny bounded n (a 3-element config) |
|
|
184
|
+
| `Vec::remove(0)` / `insert(0, _)` | `VecDeque` (`push_front`/`pop_front` are O(1)); `swap_remove` when order is free | Queue-shaped access (§C4) |
|
|
185
|
+
| Re-sorting per iteration; repeated min/max extraction | Sort once outside the loop; `BinaryHeap` for repeated extract-min/max | — |
|
|
186
|
+
| `HashMap<usize, T>` with dense, small integer keys | Index a `Vec<T>` / `Vec<Option<T>>` directly; `slab`/`slotmap` when slots are reused and keys must stay stable | Keys dense (0..n); sparse ids → keep the map |
|
|
187
|
+
| `HashSet<usize>` membership over dense integer ids | A bitset (`fixedbitset` / `bitvec`) — 1 bit per id, cache-friendly | Dense universe; sparse → keep the set |
|
|
188
|
+
| Dispatch over a small fixed string/key set | `match` on the literal, a const array, or `phf` (compile-time perfect hash) | Keys known at compile time |
|
|
189
|
+
| A tiny *dynamic* map (N ≲ 32 small entries) on a hot path | `Vec<(K, V)>` with linear scan | Below a few dozen small entries a linear scan beats hashing (no hash, better cache locality); re-measure if N can grow |
|
|
190
|
+
| Keyed lookup *plus* ordered iteration / range queries / repeated min-max | `BTreeMap`/`BTreeSet` — cache-friendly B-tree, `range()` for free | You actually use the ordering; for pure point lookups the hash map wins |
|
|
191
|
+
| Output, serde snapshot, or a test depends on map iteration order | `indexmap::IndexMap`/`IndexSet` — hash map preserving insertion order | Deterministic iteration wanted by design — also the honest fix when a test depends on iteration order (std `HashMap`'s is random per process; pinning it via the test is a §D1 flake) |
|
|
192
|
+
| Hand-rolled byte scanning | `memchr` / `memmem` — SIMD-accelerated search | Hot parsing loops; note std's `str::find` already uses these internally for common cases |
|
|
193
|
+
|
|
194
|
+
**Keys & hashing** (§E4/§B16's domain — the trust boundary rules there are canonical):
|
|
195
|
+
|
|
196
|
+
| You wrote | Consider | Gate — when it pays / when to leave it |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| Default `HashMap` (SipHash) on a hot path, keys you control | Pick by key shape: `rustc_hash::FxHashMap` for integer/tiny keys (what rustc itself runs on); `foldhash` for strings/medium keys (hashbrown's default since 0.15; `fast` and `quality` profiles); `ahash` (AES-NI, long-established) | **Trusted keys only** — on untrusted input a fixed-seed fast hasher is HashDoS; §B16 owns that boundary and it is not negotiable. Skip `fnv` (loses to Fx nearly everywhere today); `gxhash`/`wyhash` are niche wins behind §A1 + a target-CPU check (`gxhash` hard-requires AES intrinsics) |
|
|
199
|
+
| `map.get(&key.to_string())` with a `&str` already in hand | `map.get(key)` — `Borrow<str>` makes the allocation pointless | Always |
|
|
200
|
+
| `contains_key` + `insert` pair | The `entry()` API — one hash lookup instead of two, and check-then-act atomicity per §B13 | Always (synchronous only — §B2's shard-guard rule if an `.await` follows) |
|
|
201
|
+
|
|
202
|
+
**Concurrent maps — pick by access shape** (§E4's domain; §B2's guard-across-`.await` rule applies to *every* guard-returning API here):
|
|
203
|
+
|
|
204
|
+
| Access shape | Consider | Gate — when it pays / when to leave it |
|
|
205
|
+
|---|---|---|
|
|
206
|
+
| Moderate mixed read/write from many threads | `dashmap::DashMap`/`DashSet` — N shards × `RwLock` over hashbrown | The de-facto standard; degrades under hot-key contention (all writers of one key queue on its shard); holding a `Ref` across `.await` deadlocks the shard (§B2) |
|
|
207
|
+
| Read-heavy, high concurrency — especially async | `scc::HashMap`/`HashIndex` — per-bucket locking, honest `*_async` methods, no stop-the-world resize; `papaya`/`flurry` for fully lock-free reads | `papaya` (2024+) and `flurry` are newer — §A1 verify and measure against `dashmap` on *your* access shape before switching |
|
|
208
|
+
| Reads ≫ writes, snapshot staleness acceptable | `arc_swap::ArcSwap<HashMap>` — rebuild and swap the whole snapshot; `evmap`/left-right — readers never block | The gate is the **consistency model**, not the speed: writes become visible on publish/refresh, not immediately — state that in a comment |
|
|
209
|
+
| Ordered iteration / range queries under concurrency | `crossbeam-skiplist` (`SkipMap`) / `scc::TreeIndex` | Ordered + concurrent without a global lock; single-threaded ordered → plain `BTreeMap` |
|
|
210
|
+
| A single global `Mutex<HashMap<…>>` bottleneck | First ask §A2/§E4's design question — does the data shard per key? is it read-mostly? — before reaching for any concurrent map | The catalog is not a license to skip the design step; a concurrent map on top of the wrong ownership shape just moves the queue |
|
|
211
|
+
|
|
212
|
+
**When NOT to substitute**: the profile is silent and the path is cold; the swap adds a dependency for a win nobody measured; the current type is part of a public API (§C1 — changing it is a semver event, not an optimization); or the "faster" structure loses a property the code relies on (ordering, stable addresses, iteration determinism). §E6 is the binding law of this table: the catalog tells you *what to reach for*, the profiler tells you *whether to bother*.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Rust Intel — Dependencies, Macros & Ergonomics (supply-chain, clone, proc-macro, features, workspace, Deref, recompute cost)
|
|
2
|
+
|
|
3
|
+
> Module of the **rust-intel** skill. Core — operating mode, blocking protocol, enforcement tiers, the trigger table, version pins, and the category→module map — lives in `SKILL.md`. This module holds the category bodies for §A1, §C5, §C6, §C7, §C10, §C11, §E5. Tier labels (🔴/🟡/🟢; A–F) and all cross-references are preserved verbatim.
|
|
4
|
+
> **Tiers in this module:** §A1 🔴 (unverified/unnamed dependency; stale-API remainder is 🟡) · §C5 🟡 · §C6 🟡 · §C7 🟡 (typo'd cfg — 🟢 via unexpected_cfgs) · §C10 🟡 · §C11 🟡 · §E5 🟡/🟢. Derived from SKILL.md → Enforcement tiers (canonical).
|
|
5
|
+
> **Audit semantics:** 🔴 = report every occurrence; 🟡 = write-time discipline — report only load-bearing/non-obvious cases; 🟢 = clippy's, don't hand-report. Audit the *artifact* (a BANNED pattern present, a REQUIRED code artifact absent); process-REQUIREMENTs ("propose first", "ask the user") are not auditable findings.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## §A1. Stale APIs, deprecated-not-removed APIs, and slopsquatting
|
|
10
|
+
|
|
11
|
+
The class here is **APIs that compile but are wrong**, not APIs that don't exist. The pure-hallucination cases (`E0599` "method does not exist") are noise — rustc catches them and the LLM moves on. The cases that survive the compile are: the API existed in an older version of the crate and still exists in the new one with materially different semantics; the API is `#[deprecated]` but not removed; the LLM picked up a method name from a different crate and the name happens to also exist in the named crate; or — worst — the LLM hallucinated a *crate name* that an attacker has since registered on crates.io with a malicious payload.
|
|
12
|
+
|
|
13
|
+
**The trap, by sub-class:**
|
|
14
|
+
|
|
15
|
+
- **Stale-but-still-valid APIs.** `tokio` 0.2 `mpsc::channel(_)` returned a different tuple shape than `tokio` 1.x; `rand` 0.8 `thread_rng()` was renamed to `rng()` in 0.9 but the old function lingers in code patterns. The LLM emits the older form, it compiles against the pinned version because the symbol is still present (or trivially adapted), and behavior diverges from the user's mental model.
|
|
16
|
+
- **Deprecated-not-removed APIs.** `#[deprecated]` emits a warning, not an error. LLMs routinely ignore the warning channel and ship deprecated calls. Each deprecated call is a future break.
|
|
17
|
+
- **Wrong-version-of-crate APIs.** `serde_json::from_str` exists in every version, but `serde_json::Value::take` did not exist before a specific point. The compile succeeds against the pinned version *because the version pinned is recent enough*, but the LLM has no proof of that — it guessed and was lucky.
|
|
18
|
+
- **Slopsquatting (supply-chain).** Hallucinated crate names that an adversary has registered on crates.io. Compiles, runs, exfiltrates secrets, and `cargo build --offline` would not have helped (the malicious payload lives inside a dependency the build script reaches for). Published "package-import hallucination" studies (Lanyado / Spracklen) report elevated hallucination rates for Rust crate names relative to other ecosystems; precise figures require checking against the primary source.
|
|
19
|
+
|
|
20
|
+
**REQUIRED**:
|
|
21
|
+
- Before calling any method on a third-party type, check that it exists *with the documented semantics* in the **exact version pinned in `Cargo.toml`**. "It compiled" is not proof — semantics drift across minor versions in pre-1.0 crates.
|
|
22
|
+
- For high-churn crates (`tokio`, `axum`, `hyper`, `reqwest`, `sqlx`, `serde`, `tonic`, `tower`, `clap`, `rand`), if uncertain about an API or its semantics, **say so explicitly** and ask the user to confirm or run `cargo doc --open`.
|
|
23
|
+
- Treat `#[deprecated]` warnings as errors. If the symbol I want to emit is deprecated in the pinned version, switch to the replacement before writing.
|
|
24
|
+
- Pre-1.0 crates (any version with leading `0.`) have **breaking changes between minor versions**. Treat 0.6 → 0.7 with the same suspicion as 1.x → 2.x.
|
|
25
|
+
|
|
26
|
+
**BANNED**:
|
|
27
|
+
- Method calls on types where I have not internally verified the method exists *and means what I think it means* in the pinned version.
|
|
28
|
+
- Mixing API styles from different major versions (e.g., axum 0.6 routers with axum 0.7 handlers).
|
|
29
|
+
- Adding a crate to `Cargo.toml` that the user did not name and that I have not independently verified exists.
|
|
30
|
+
|
|
31
|
+
**Security note: slopsquatting**. Hallucinated *crate names* (not just methods) are a supply-chain attack vector that **survives compilation and runs malicious code**. Adversaries monitor common LLM crate-name hallucinations and **register those names on crates.io with malicious payloads**. This is the canonical Tier A category: the LLM's "fix" for "I need a crate that does X" compiles cleanly and silently runs untrusted code.
|
|
32
|
+
|
|
33
|
+
**Real attack cases (2022–2026)** — these are not hypothetical:
|
|
34
|
+
- `rustdecimal` — typosquat of `rust_decimal` (the real crate has ~100M all-time downloads). The malicious crate, documented in the CrateDepression incident (2022), targeted CI pipelines.
|
|
35
|
+
- `faster_log`, `async_println` — malicious crates designed to scan for and exfiltrate Solana/Ethereum private keys; reached thousands of downloads before takedown.
|
|
36
|
+
- Supply-chain attacks across software ecosystems rose materially in 2025 (published year-over-year estimates cluster around +70–75% ecosystem-wide; no crates.io-specific figure is published).
|
|
37
|
+
|
|
38
|
+
Concrete defenses:
|
|
39
|
+
- I do not add a crate to `Cargo.toml` unless the user explicitly named it OR I verified its existence by reading the project's existing dependencies.
|
|
40
|
+
- For any new dependency I suggest, I flag it as a *suggestion to verify*, not a fait accompli: "I'd add `deadpool-postgres` for connection pooling — please verify on crates.io before adding."
|
|
41
|
+
- I never invent variations of well-known crate names (`tokio-utils` does not exist, `tokio-util` does; `serde-json` does not exist as a separate crate, `serde_json` does; `rust-decimal` does not exist, `rust_decimal` does — and the typo'd variant has been weaponized).
|
|
42
|
+
- Surface every newly-added `Cargo.toml` dependency in the post-flight summary so the user can audit it.
|
|
43
|
+
|
|
44
|
+
**Build-time code execution (a distinct supply-chain vector).** Slopsquatting is about *hallucinated names*; this is about *what a dependency does at build time*. A crate's `build.rs` and any proc-macro it exports run arbitrary code on the developer's machine and in CI **during `cargo build`**, before any runtime guard exists — this is the mechanism behind the malicious crates above, and such payloads read `~/.cargo/credentials`, `~/.ssh`, `.env`, and CI secrets. A typosquat that swaps `-` for `_` (or appends a language suffix), plus dependency confusion (a private crate name shadowed by a public one on a default registry), are the same class. Defenses: pin exact versions and commit `Cargo.lock`; for a newly-added *direct* dependency that is not a well-known crate, skim its `build.rs`/proc-macro before the first build; and for the transitive graph (which you cannot read by hand) lean on `cargo-deny` / `cargo-audit` (RustSec advisory DB), `cargo-vet` (attest that each dependency has been human-audited), the committed `Cargo.lock`, and `--locked`/vendored builds.
|
|
45
|
+
|
|
46
|
+
## §C5. Reflexive `.clone()` as a borrow-checker silencer
|
|
47
|
+
|
|
48
|
+
**The trap**: when borrow checker complains, the LLM's path of least resistance is to insert `.clone()` or `.to_string()` until errors disappear. The code compiles. The performance cost is invisible until profiling. This is a *different* failure mode from §C4 — it's not an idiom drift, it's a reflexive *fix-it strategy* that resolves a real borrow problem with a hidden allocation.
|
|
49
|
+
|
|
50
|
+
**Why this happens**: gradient descent rewards "compiles" heavily; the model learned that adding `.clone()` is a reliable way to make red squiggles go away. The cost (allocation, deep copy of `Vec<T>`, etc.) isn't penalized anywhere in training.
|
|
51
|
+
|
|
52
|
+
**Prompt triggers**: any prompt involving a borrow checker error in the conversation history; "fix the lifetime issue"; "make this compile"; refactoring sessions where the user is iterating on a function signature.
|
|
53
|
+
|
|
54
|
+
**REQUIRED**:
|
|
55
|
+
- Before inserting `.clone()`, ask: can this be solved by restructuring ownership (split borrows, borrow earlier-release later, take `&self` instead of `self`)?
|
|
56
|
+
- For `Copy` types (i32, bool, small struct of `Copy` fields), `.clone()` is a code smell — `clippy::clone_on_copy` exists for a reason. Never insert it.
|
|
57
|
+
- For `&str` → `String` conversions purely to escape a lifetime: re-examine the lifetime first. The String allocation is often masking the real problem from §B1.
|
|
58
|
+
- For `Vec<T>` clones in hot paths: consider `&[T]`, `Cow<'_, [T]>`, or `Arc<[T]>`.
|
|
59
|
+
- A `.clone()` introduced *to silence a borrow error* (the §C5 reflex) gets a one-line inline justification; routine clones, `Arc::clone`/`Rc::clone`, and `Copy`-type clones are 🟢 (clippy) / 🟡 (write-time) — not surfaced.
|
|
60
|
+
|
|
61
|
+
**BANNED**:
|
|
62
|
+
- `.clone()` on a `Copy` type.
|
|
63
|
+
- `String::from(s)` or `s.to_string()` immediately followed by use as `&str` (the original would have worked).
|
|
64
|
+
- Cloning inside a loop where the cloned value is only read.
|
|
65
|
+
- Replacing `&T` with `T` in a function signature just to make a call site compile.
|
|
66
|
+
|
|
67
|
+
## §C6. Procedural macro hygiene
|
|
68
|
+
|
|
69
|
+
**The trap**: proc-macros generate code that's pasted into the user's crate. If the macro writes `Option<T>`, it resolves at the call site — and if the user has `type Option = MyOption;`, the macro silently breaks. Hygiene violations in proc-macros are invisible at macro authoring time and only surface at user sites.
|
|
70
|
+
|
|
71
|
+
**REQUIRED in any proc-macro output**:
|
|
72
|
+
- Use absolute paths for every standard library item: `::core::option::Option<T>`, `::core::result::Result<T, E>`, `::std::vec::Vec<T>`, `::std::string::String`. Never bare `Option`, `Result`, `Vec`, `String`.
|
|
73
|
+
- For external traits: `::serde::Serialize`, not `Serialize` (and require the macro user to add `serde` as a dependency).
|
|
74
|
+
- For error reporting in macro expansion, use `syn::Error::to_compile_error()` returning `TokenStream`, which surfaces correctly at the user's call site. **Never `panic!`** in proc-macros — the user sees an opaque panic message without source location.
|
|
75
|
+
- For `#[derive]` macros that add bounds (e.g., `#[derive(Clone)]` adding implicit `T: Clone`), consider whether this matches user intent. For finer control, use `derive_more` or `derivative` and document the choice.
|
|
76
|
+
|
|
77
|
+
## §C7. Cargo feature flag hygiene
|
|
78
|
+
|
|
79
|
+
**The trap**: Cargo accepts unknown feature names silently. A typo like `#[cfg(feature = "widnows")]` becomes dead code that never compiles, never runs, and never warns — until production reveals a missing code path.
|
|
80
|
+
|
|
81
|
+
**REQUIRED**:
|
|
82
|
+
- Declare every feature in `[features]` in `Cargo.toml`. Rust 1.80+ automatically emits the `unexpected_cfgs` lint for any `#[cfg(feature = "...")]` whose name doesn't appear there — no extra flag needed. Treat the lint as `deny`, not `warn`, in CI.
|
|
83
|
+
- Every `feature` in `Cargo.toml` is mirrored exactly in every `#[cfg(feature = "...")]`. Names are case-sensitive and exact.
|
|
84
|
+
- Avoid feature-gated `pub` fields in structs — they break the public API between feature combinations. If a field is conditional, the whole struct or the whole module should be conditional.
|
|
85
|
+
- Test the full feature matrix in CI: `cargo hack --feature-powerset check` or equivalent, at least for libraries.
|
|
86
|
+
- For platform-conditional dependencies with features (`[target.'cfg(...)'.dependencies]`), the resolver version matters (mirror of §C10). Under **resolver v1** (default before edition 2021) `features = [...]` on a target-specific dependency activated *globally*, even for targets not being built — the cargo#2524 gotcha. **Resolver v2** (default since edition 2021; this spec targets 2024) fixes it: per the Cargo book, "features for target-specific dependencies are not enabled if the target is not currently being built." So on a 2021+/v2 project the global-activation surprise no longer applies; it resurfaces only if a crate is pinned to `resolver = "1"` or pre-2021 edition. Verify your `resolver` before relying on either behavior.
|
|
87
|
+
|
|
88
|
+
## §C10. Workspace feature unification surprises
|
|
89
|
+
|
|
90
|
+
**The trap**: Cargo unifies features across the entire workspace dependency graph — when two crates depend on the same upstream crate, Cargo merges their requested feature sets into one. The scope of the merge depends on the resolver: under **resolver v2** (default since edition 2021; this spec targets 2024) a feature activated only in one crate's `[dev-dependencies]` unifies with another crate's `[dependencies]` *only within builds that pull in dev targets* — `cargo test`, `cargo build --all-targets`, `--workspace` — and **not** in a clean `cargo build --release`. The "leaks into the release build" behavior is **resolver v1**. Either way the surprise is the same: local tests pass, the workspace builds, but the downstream consumer who depends on just one of the workspace crates suddenly fails because their feature set doesn't match the unified one.
|
|
91
|
+
|
|
92
|
+
**BANNED**:
|
|
93
|
+
- `default = ["heavy-dep"]` in `[features]` of a workspace member where `heavy-dep` is only needed by *some* consumers — every consumer who doesn't disable defaults pays the cost.
|
|
94
|
+
- Activating a feature in `[dev-dependencies]` of crate A which also appears in `[dependencies]` of crate B sharing the workspace — under resolver v1 the feature leaks into B's release build via Cargo's feature unification; under resolver v2 (default since edition 2021) it unifies only within builds that include dev targets (`cargo test`, `--all-targets`, `--workspace`).
|
|
95
|
+
- Treating workspace-internal features as private. They are visible (and unifiable) across the whole workspace and into any external consumer who pulls in any member crate.
|
|
96
|
+
- Members of one workspace pinning the **same dependency at drifting versions** (`serde = "1.0.200"` in one member, a looser `"1"` resolving to a semver-incompatible point elsewhere). Cargo can link *multiple copies* into one binary — larger artifact, slower build, and two distinct `serde::Error` types that don't interoperate (`expected Error, found Error`). The lockfile hides it until a value crosses a member boundary.
|
|
97
|
+
|
|
98
|
+
**REQUIRED**:
|
|
99
|
+
- Default features in a workspace member = the **minimum truly required** for the crate to function at all. Every additional default is a tax on every downstream consumer.
|
|
100
|
+
- Run `cargo hack --feature-powerset --no-dev-deps check` in CI to detect feature combinations that don't compile (the `--no-dev-deps` flag prevents dev-only features from leaking into the matrix).
|
|
101
|
+
- For workspace-internal feature toggles, prefer `[workspace.metadata]` + `build.rs` `cargo:rustc-cfg=...` over `[features]` — `cfg` flags do not unify across the workspace the way features do.
|
|
102
|
+
- Document on every workspace member's `Cargo.toml`: which features are public (intended for external consumers) vs internal (used only by other workspace members).
|
|
103
|
+
- Declare shared dependencies and their versions once in `[workspace.dependencies]` and inherit them with `dep.workspace = true` in each member — one version, one linked copy, one feature-unified set, audited in one place.
|
|
104
|
+
- **Extract a crate late, not early.** A workspace tempts speculative splitting ("one crate per module"). A premature boundary freezes an API you do not yet understand — every cross-crate call becomes a `pub` semver surface (§C1) — and forces exactly the feature/version coordination above. Split a crate out when there is *real* reuse, a *stable* boundary, or a concrete reason (compile-time parallelism, a separate publish cadence, a `proc-macro`/`build.rs` that must be its own crate). The opposite rot — logic copy-pasted across members and fixed in only one place — is the signal that extraction is now overdue, not premature.
|
|
105
|
+
|
|
106
|
+
## §C11. `Deref` polymorphism antipattern
|
|
107
|
+
|
|
108
|
+
**The trap**: `impl Deref<Target = Inner> for Wrapper` makes `wrapper.field_of_inner` and `wrapper.method_of_inner()` work transparently. The LLM uses this to fake inheritance — `struct UserAdmin(User); impl Deref<Target = User> for UserAdmin` — and the code compiles, runs, and looks elegant for a while. The breakdown comes when `UserAdmin` needs to participate in a trait `User` does not impl, or vice versa: the Rust API Guidelines explicitly call this out as **C-DEREF** ("Only smart pointers implement `Deref` and `DerefMut` (C-DEREF). ... The traits should be used only for that purpose."). Trait resolution does not look through `Deref` for trait bounds, only for method calls, so generic functions taking `User` will not accept `UserAdmin`, generic functions taking `UserAdmin` will not see `User`'s trait impls, and downstream code grows ad-hoc casts and `as_ref()` calls.
|
|
109
|
+
|
|
110
|
+
**BANNED**:
|
|
111
|
+
- `impl Deref<Target = Inner> for Wrapper` where `Wrapper` is not conceptually a *smart pointer to* `Inner`. Wrappers, newtypes for additional invariants, and "extension types" are not smart pointers.
|
|
112
|
+
- Using `Deref` to expose all of `Inner`'s methods through `Wrapper` for ergonomic shorthand — this leaks the inner's API surface into the wrapper's, and any future addition to `Inner` becomes part of `Wrapper`'s public API too (semver hazard, mirrors §C1).
|
|
113
|
+
- `impl DerefMut<Target = Inner> for Wrapper` on a wrapper that adds invariants — the `DerefMut` lets callers bypass every method `Wrapper` defined to maintain those invariants.
|
|
114
|
+
|
|
115
|
+
**REQUIRED**:
|
|
116
|
+
- `Deref` is reserved for smart pointers: `Box`, `Rc`, `Arc`, `Cow`, `MutexGuard`, `RwLockReadGuard`, `String → str`, `Vec<T> → [T]`, custom guards (`MyHandle<'a, T>` where `T` is the pointee). The relationship must be *pointer-like* (the wrapper owns/references the pointee; the wrapper is morally transparent to the pointee).
|
|
117
|
+
- For composition without inheritance, write explicit accessors: `impl UserAdmin { fn user(&self) -> &User { &self.0 } }`. This keeps the API surface of `UserAdmin` separate from `User` and makes the composition explicit at every call site.
|
|
118
|
+
- Cite the Rust API Guidelines **C-DEREF** rule in code review when this pattern appears: *"Only smart pointers implement `Deref` and `DerefMut` (C-DEREF). ... The traits should be used only for that purpose."*
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## §E5. Work already done — *The cheapest computation is the one you did once and kept.*
|
|
123
|
+
|
|
124
|
+
- **Where it shows up**: `Regex::new(...)` (or a parser, schema, template) compiled inside the function that uses it, recompiled every call; a pure derived value recomputed instead of cached; unbuffered I/O — one syscall per small `read`/`write`; a serializer allocating a fresh buffer per item; a log line whose fields are formatted eagerly even when the level is filtered out; dynamic dispatch (`Box<dyn Trait>`) on a hot path where the type set is closed.
|
|
125
|
+
- **The cheaper move**: hoist compile-once values into `LazyLock`/`OnceLock` (§A2) — not a panicking initializer (§A2); wrap I/O in `BufReader`/`BufWriter`; reuse serialization buffers; let `tracing` defer field formatting (record fields, don't `format!` the message) or guard with `if enabled!`; on a closed type set prefer generics or `enum` dispatch over `dyn` when monomorphization cost is acceptable.
|
|
126
|
+
- **Leave it when**: the work is genuinely once-per-process already, the value changes every call, or the indirection keeps the design open and the path is cold.
|
|
127
|
+
- 🟡. Cross: §A2.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Rust Intel — Drop Order & RAII (incl. edition-2024 drop scope)
|
|
2
|
+
|
|
3
|
+
> Module of the **rust-intel** skill. Core — operating mode, blocking protocol, enforcement tiers, the trigger table, version pins, and the category→module map — lives in `SKILL.md`. This module holds the category bodies for §B4 (and §B4a). The async-Drop case is §B22 in `async.md`. Tier labels (🔴/🟡/🟢; A–F) and all cross-references are preserved verbatim.
|
|
4
|
+
> **Tiers in this module:** §B4 🟡 · §B4a 🟡. Derived from SKILL.md → Enforcement tiers (canonical).
|
|
5
|
+
> **Audit semantics:** 🔴 = report every occurrence; 🟡 = write-time discipline — report only load-bearing/non-obvious cases; 🟢 = clippy's, don't hand-report. Audit the *artifact* (a BANNED pattern present, a REQUIRED code artifact absent); process-REQUIREMENTs ("propose first", "ask the user") are not auditable findings.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## §B4. Drop order and RAII contracts
|
|
10
|
+
|
|
11
|
+
**The trap**: implicit `Drop` for transactions, file handles, async resources has library-specific contracts. `sqlx` implicit-rolls-back inside the async runtime (blocking). `deadpool-postgres` sends rollback to a background task that may never run. The semantics live in library source, not signatures.
|
|
12
|
+
|
|
13
|
+
**REQUIRED** for any DB transaction / file handle / network resource:
|
|
14
|
+
- After the last fallible operation that might fail (e.g., `tx.commit().await`), **assume the resource's `Drop` runs in an undefined state**. Do not rely on it for correctness.
|
|
15
|
+
- For transactions: explicit `commit().await?` on success path, explicit `rollback().await?` on error path, **and** acknowledge the failure mode of `commit().await` itself failing (the tx is then in a library-specific state — check the docs).
|
|
16
|
+
- Read the version-specific `Drop` impl docs for the library being used. State the version you assumed in a comment.
|
|
17
|
+
- Be aware that holding multiple drop-significant guards (file + DB tx + lock) creates an ordering problem: Rust drops in reverse declaration order, but the *correct* order depends on the semantics. State which order matters.
|
|
18
|
+
|
|
19
|
+
**BANNED**:
|
|
20
|
+
- `std::process::exit(...)` from any code path where stack-local guards (database transactions, file handles, lock guards, logger flushers) still need to run their `Drop`. `process::exit` **does not unwind** — none of those `Drop` impls execute. Return a `Result` from `main`, or call `drop(guard)` explicitly on every guard before `process::exit`.
|
|
21
|
+
- A `Drop::drop` body that can itself panic while panicking is already in flight (the second panic aborts the process via `panic_in_drop`). The idiomatic primary guard is `std::thread::panicking()`: skip or relegate the fallible work when the thread is already unwinding (`if !std::thread::panicking() { … }`), so `drop` never adds a second panic to an in-flight one. If `drop` does anything fallible, isolate it in `catch_unwind` and downgrade the inner panic to a logged error. **Precondition for this fix:** `catch_unwind` only catches an *unwinding* panic — under `[profile.*] panic = "abort"` a panic aborts the process immediately and unwinds nothing, so the `catch_unwind` (and the logging behind it) never runs. The guard is real only under `panic = "unwind"` (the default). `catch_unwind` also takes an `UnwindSafe` closure; reach for `AssertUnwindSafe` only after reasoning that no observer can see broken invariants across the caught panic — do not wrap blindly to silence the bound.
|
|
22
|
+
- `mem::forget(guard)` or wrapping a guard in `ManuallyDrop` without a later manual drop — both silently disable the RAII release (file descriptor, DB connection, lock guard never freed). This is the §C5 reflexive-`.clone()` reflex applied to `Drop`: a quick way to silence a move/borrow complaint that leaks the resource instead.
|
|
23
|
+
|
|
24
|
+
**Drop at process exit — memory vs. resource (the fork that resolves the `process::exit` tension above)**: the BANNED `process::exit` / `mem::forget` rules above are about *resource-releasing* `Drop`. Not every `Drop` releases a resource, and the two cases want opposite treatment at shutdown:
|
|
25
|
+
- **Resource-cleanup `Drop`** (flushes a file, commits/rolls back a transaction, releases an OS lock, sends a connection-close, decrements a cross-process refcount): **must run**. Skipping it via `process::exit` / `mem::forget` / `ManuallyDrop` is the §B4 bug above — data loss or a stuck external resource.
|
|
26
|
+
- **Memory-only `Drop`** (a large `HashMap`, a deep tree of `Box`, an arena, a parsed AST — whose `drop` does nothing but free heap the OS is about to reclaim wholesale): running it at process exit is *wasted work*. Walking a multi-gigabyte structure node-by-node can add seconds of teardown — or look like a hang — before an exit that frees the whole address space anyway. Here `std::mem::forget`, `ManuallyDrop`, or letting `std::process::exit` skip it is the **correct** move, not a leak that matters.
|
|
27
|
+
|
|
28
|
+
So classify `Drop` by *what it does*, not by reflex. A literal-minded "always run every destructor" stalls shutdown on big structures; a literal-minded "just `process::exit` to exit fast" loses a transaction. Neither rule is right alone — skip a destructor at exit only when it is provably memory-only.
|
|
29
|
+
|
|
30
|
+
**Recursive `Drop` can overflow the stack** (ties to §B7): the auto-generated `Drop` for a self-owning recursive type — a long `Box`-linked list, a deep `Box<Node>` tree, an unbalanced syntax tree — recurses once per level as it tears down. On attacker-controlled or merely large depth that recursion overflows the stack: a `SIGSEGV`/abort, **not** a catchable panic (the §B7 DoS shape, arriving through the destructor). Tests on shallow data pass. For any recursive type whose depth is not provably bounded, hand-write an **iterative** `Drop` that unlinks nodes into a worklist `Vec` and drops them in a loop, rather than relying on the recursive default.
|
|
31
|
+
|
|
32
|
+
**Drop-order shutdown deadlock** (the concrete case of the ordering note above): a struct — or a function's locals — holding both a worker `JoinHandle`/thread and the `Sender` that feeds it deadlocks at teardown if the handle is joined **before** the channel closes. The worker blocks forever on `recv()`, so the `join()` blocks forever, so the program never exits. Rust drops struct fields in **declaration order** (top to bottom) and locals in **reverse** declaration order; the `Sender` must drop (or be explicitly closed) *before* the join. Fix: `drop(sender)` — or signal shutdown — explicitly before `handle.join()` / `.await`, or order the fields so the `Sender` precedes the `JoinHandle`. Don't leave it to incidental field order; state which drops first and why.
|
|
33
|
+
|
|
34
|
+
Async resources have an additional constraint that `Drop` cannot honor — see §B22 for the async-`Drop`-is-not-real problem.
|
|
35
|
+
|
|
36
|
+
### §B4a. Edition-2024 temporary-scope drop-order changes
|
|
37
|
+
|
|
38
|
+
**The trap**: migrating a crate to edition 2024 silently changes *when* temporaries drop in two places — with no runtime error and usually green tests, but a different drop order under locks and RAII guards.
|
|
39
|
+
|
|
40
|
+
- **`if let … {} else {}` scrutinee** (`if_let_rescope`): in edition 2024 a temporary created in the `if let` scrutinee drops **before** the `else` block (and at the end of the `then` block), not at the end of the whole `if`/`else`. The canonical hazard is an `RwLock`/`Mutex` deadlock: `if let Some(v) = lock.read().get(&k) { … } else { lock.write().insert(…) }` deadlocks in 2021 (the read guard is still alive in the `else`) and silently *stops* deadlocking in 2024 — or, conversely, code that relied on the temporary living into the `else` now drops it early. `cargo fix --edition` auto-rewrites to a `match` to preserve 2021 behavior; review those rewrites.
|
|
41
|
+
- **Tail-expression temporaries** (`tail_expr_drop_order`, RFC 3606): in edition 2024 temporaries in a block's tail expression drop **after** the tail value but **before** the block's locals, not at end of the enclosing statement. This shifts the drop order of any custom-`Drop` value (a `MutexGuard`, transaction handle, span guard) sitting in tail position. The lint is **advisory and has no autofix** — migration will not flag it for you unless you read the warning. A trailing `if let … {}` with no semicolon is *both* a tail expression and an `if let`, so it hits both rules at once.
|
|
42
|
+
- **let-chains** (`if let A && let B`, stable 1.88, edition 2024) follow the same `if let` temporary-scope rule — reason about each chained temporary's drop point explicitly. `let`-chains also reached `match` *guards* (`match x { v if let Ok(y) = f(v) => … }`, stable **1.95**, all editions — verify against your pinned toolchain) — these do **not** share the `if let … else` drop hazard (a guard has no `else` arm), so no special drop-order review is needed there beyond normal guard-temporary scoping.
|
|
43
|
+
- **never-type fallback** (edition 2024) is mostly out of scope for this spec: changing `!`'s fallback from `()` to `!` is a *compile-time* break in the common case (code relying on `Default::default()` inferring `()` stops compiling), and the one genuinely silent-at-runtime interaction — fallback flowing into an `unsafe` call — is guarded by the `never_type_fallback_flowing_into_unsafe` lint, which is **warn-by-default in all editions since Rust 1.80** and **deny-by-default in edition 2024** (which stabilized in **Rust 1.85**). Because the dangerous case is deny-linted rather than silent, it sits outside this document's "survives the compiler" focus; annotate the type explicitly (`zeroed::<()>()`, `<() as Default>::default()`) when the lint fires.
|
|
44
|
+
- Edition 2024 also changes **`impl Trait` lifetime capture**: a return-position `-> impl Trait` now captures *all* in-scope generic lifetime parameters by default. `fn f<'a>(x: &'a [u8]) -> impl Iterator<Item = u8>` ties the returned iterator to `'a` in edition 2024 where it did not before — a borrow that used to end early now lives as long as the input. Opt out with a precise-capture bound: `+ use<>` (capture nothing) or `+ use<'b, T>` (capture only what you name). On a 2021→2024 migration this silently changes how long the return value borrows its input (this is the §B1b lifetime-leaking shape arriving via edition migration).
|
|
45
|
+
|
|
46
|
+
**REQUIRED**:
|
|
47
|
+
- On any 2021→2024 edition migration, run `cargo fix --edition`, then manually review every `tail_expr_drop_order` warning (no autofix) and every `if let … else` that holds a lock guard or other RAII type in its scrutinee.
|
|
48
|
+
- For lock guards specifically, bind the guard to a `let` with an explicit scope rather than relying on a temporary's lifetime — that makes the drop point edition-independent. See §B9 (lock order) and §B2 (guard across `.await`).
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Rust Intel — Lifetimes & Public API Surface
|
|
2
|
+
|
|
3
|
+
> Module of the **rust-intel** skill. Core — operating mode, blocking protocol, enforcement tiers, the trigger table, version pins, and the category→module map — lives in `SKILL.md`. This module holds the category bodies for §B1, §C1 (and §C1a), §A3. Tier labels (🔴/🟡/🟢; A–F) and all cross-references are preserved verbatim.
|
|
4
|
+
> **Tiers in this module:** §B1a/b 🟡 · §C1 🔴 (blanket impl in pub API of a published library; rest is 🟡) · §C1a 🟡 · §A3 🟡. Derived from SKILL.md → Enforcement tiers (canonical).
|
|
5
|
+
> **Audit semantics:** 🔴 = report every occurrence; 🟡 = write-time discipline — report only load-bearing/non-obvious cases; 🟢 = clippy's, don't hand-report. Audit the *artifact* (a BANNED pattern present, a REQUIRED code artifact absent); process-REQUIREMENTs ("propose first", "ask the user") are not auditable findings.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## §B1. Lifetime laundering and lifetime leaking
|
|
10
|
+
|
|
11
|
+
Two distinct lifetime traps LLMs make with high frequency. They look similar from the outside (both involve `<'a>` in a signature where it shouldn't be) but the diagnostic and the fix are different. Treat them as separate sub-categories.
|
|
12
|
+
|
|
13
|
+
### §B1a. Lifetime laundering
|
|
14
|
+
|
|
15
|
+
**The trap**: one `'a` parameter binds both an input and a cached output, hiding a lifetime collapse from the local view. The signature compiles in isolation but the function becomes uncallable in practice.
|
|
16
|
+
|
|
17
|
+
**Why this happens**: the transformer's attention doesn't extend beyond the function body. Locally, `<'a>` looks elegant; the cross-function constraint is invisible.
|
|
18
|
+
|
|
19
|
+
**BANNED pattern (synthetic):**
|
|
20
|
+
```rust
|
|
21
|
+
fn lookup<'a>(s: &'a str, cache: &mut HashMap<String, &'a str>) -> &'a str { ... }
|
|
22
|
+
// ^^^ caller's `s` lifetime
|
|
23
|
+
// leaks into the cache type
|
|
24
|
+
```
|
|
25
|
+
Compiles in isolation; collapses to an empty lifetime when called twice with different inputs.
|
|
26
|
+
|
|
27
|
+
**BANNED pattern (realistic — typical LLM output for "add caching"):**
|
|
28
|
+
```rust
|
|
29
|
+
use std::collections::HashMap;
|
|
30
|
+
|
|
31
|
+
fn first_word<'a>(s: &'a str, cache: &mut HashMap<String, &'a str>) -> &'a str {
|
|
32
|
+
if let Some(cached) = cache.get(s) {
|
|
33
|
+
return cached;
|
|
34
|
+
}
|
|
35
|
+
let word = s.split_whitespace().next().unwrap_or("");
|
|
36
|
+
cache.insert(s.to_string(), word);
|
|
37
|
+
word
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
Compiles, passes unit tests with a single input. Fails the moment a second call site passes a `&str` with a different lifetime: the cache forces all entries to share one `'a`, which the borrow checker collapses to the empty intersection.
|
|
41
|
+
|
|
42
|
+
**Prompt triggers that produce this**: "add caching to this function", "memoize", "speed up by storing results", "build a lookup". Whenever the user mentions caching of returned references, this category activates.
|
|
43
|
+
|
|
44
|
+
**REQUIRED**:
|
|
45
|
+
- Separate input and output lifetimes (`<'input, 'cache>`) when they should be independent, OR store owned data (`HashMap<String, String>`).
|
|
46
|
+
- For any function returning `&T` derived from inputs, write a comment showing two consecutive calls with disjoint inputs before the signature is final.
|
|
47
|
+
- Higher-Ranked Trait Bounds (`for<'a> Fn(&'a T) -> &'a U`) deserve extra care: do not drop `for<'a>` when generalizing.
|
|
48
|
+
|
|
49
|
+
### §B1b. Lifetime leaking through public APIs
|
|
50
|
+
|
|
51
|
+
**The trap**: exposing `'a` in a *public* function signature when the lifetime is an implementation detail. The function compiles, the lifetime is genuine, and the signature is technically more "zero-copy" than the alternative — but every downstream caller is now forced to juggle that lifetime through their own code.
|
|
52
|
+
|
|
53
|
+
**Distinct from §B1a**: laundering is *one `'a` binding too many things inside one function*; leaking is *exposing an `'a` in a `pub` signature that should not have been part of the public API at all*. A function can suffer from leaking without any laundering, and vice versa.
|
|
54
|
+
|
|
55
|
+
**BANNED in published library APIs unless zero-copy is an explicitly documented design goal**:
|
|
56
|
+
```rust
|
|
57
|
+
// Forces every caller to track 'a through their own code:
|
|
58
|
+
pub fn parse<'a>(source: &'a str) -> Document<'a> { ... }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**REQUIRED**:
|
|
62
|
+
- Default to owned return types in public APIs: `pub fn parse(source: &str) -> Document { ... }` where `Document` owns its data.
|
|
63
|
+
- If zero-copy is a real design requirement, document it explicitly and consider exposing both variants (`parse` returning owned + `parse_borrowed` returning the lifetime-parameterized version) so callers opt in.
|
|
64
|
+
- Note any `pub fn` with a non-`'static` output lifetime inline (at write time) so the user can confirm the lifetime is intentional, not residual.
|
|
65
|
+
|
|
66
|
+
## §C1. Blanket impls in public APIs (semver hazard)
|
|
67
|
+
|
|
68
|
+
**The trap**: `impl<T: Display> Bar for T` in a published crate is a versioning landmine. Consumers may have `impl Bar for MyType` that becomes ambiguous when an upstream blanket impl is added or narrowed. The breakage surfaces months later on consumer CI, not the author's.
|
|
69
|
+
|
|
70
|
+
**REQUIRED in any `pub` API**:
|
|
71
|
+
- Blanket `impl<T: Bound>` only when the trait is **sealed** (private supertrait the crate controls):
|
|
72
|
+
```rust
|
|
73
|
+
mod sealed { pub trait Sealed {} }
|
|
74
|
+
pub trait MyTrait: sealed::Sealed { ... }
|
|
75
|
+
```
|
|
76
|
+
- Otherwise: write per-type impls or use a marker trait the crate exposes for opt-in.
|
|
77
|
+
- For any public trait being added, explicitly state in a comment whether it is sealed or open to external impl.
|
|
78
|
+
- Respect orphan rules: never `impl ForeignTrait for ForeignType`. Use the newtype pattern: `pub struct MyWrapper(pub Foreign);`.
|
|
79
|
+
- For **zero-cost** newtypes, prefer `#[repr(transparent)] pub struct MyWrapper(Foreign);` — this guarantees the same layout, size, and alignment as `Foreign`, so it can be transmuted to/from `Foreign` (with the usual `// SAFETY:` discipline) and crosses FFI boundaries identically. Without `#[repr(transparent)]`, the layout is `#[repr(Rust)]` (per §B5: stable attribute, unspecified layout) and you have *no* guarantee that the wrapper is a pure compile-time fiction — even for a single-field struct, the compiler is technically free to add padding or change alignment.
|
|
80
|
+
|
|
81
|
+
## §C1a. Missing `#[non_exhaustive]` on a published API's enums and structs
|
|
82
|
+
|
|
83
|
+
**The trap**: a public `enum` or `struct` in a *published* crate, declared without `#[non_exhaustive]`, freezes its shape into the semver contract. Adding a variant or a field later is a **major** breaking change: downstream `match` arms stop being exhaustive (a hard compile error in consumer crates) and downstream struct-literal construction breaks. The author's crate compiles fine; the break surfaces on consumer CI — exactly the §C1 delayed-blast pattern. LLMs reliably omit the attribute, most damagingly on **error enums**, which are the types downstream code matches on most. (This is the author-side rule; §B6 covers the *consumer* side — treating someone else's enum as if it were `#[non_exhaustive]`.)
|
|
84
|
+
|
|
85
|
+
**REQUIRED in a published library** (not bin / internal / workspace-private crates):
|
|
86
|
+
- Mark a public `enum` — especially an error or protocol/event enum — `#[non_exhaustive]` when future variants are plausible, which is the default assumption for those kinds.
|
|
87
|
+
- Mark a public `struct` `#[non_exhaustive]` when future fields are plausible. This forbids downstream struct-literal construction (`Foo { a, b }`), so **ship a constructor or builder** (`Foo::new(...)`) at the same time — otherwise the type is unconstructable by consumers.
|
|
88
|
+
- Accept the cost: downstream must write a wildcard arm (`_ => …`) and `..` in patterns. That cost *is* the feature — it buys the right to grow the type without a major version bump.
|
|
89
|
+
|
|
90
|
+
**Calibration — do not slap it on everything**:
|
|
91
|
+
- Only the **public** surface of a **published** crate. In a binary, an internal module, or a workspace-private crate there is no external consumer, so the attribute is pure friction — skip it.
|
|
92
|
+
- Not for genuinely closed types whose shape is complete by definition (`enum Direction { North, South, East, West }`, a fixed `struct Rgb { r, g, b }`). Forcing a `_ =>` arm on a type that will never grow only hides real non-exhaustiveness bugs.
|
|
93
|
+
- It is **not** retroactive insurance: adding `#[non_exhaustive]` to an *already-published* exhaustive type is itself a breaking change. Decide at first publication, flag the decision inline (at write time).
|
|
94
|
+
|
|
95
|
+
This is the §C1/§A3 semver discipline applied to a type's *data shape* rather than its impls or visibility: every detail of a `pub` type's shape is a commitment unless you opt out of it up front.
|
|
96
|
+
|
|
97
|
+
## §A3. `pub` as a hammer for `E0603`
|
|
98
|
+
|
|
99
|
+
**The trap**: `rustc` emits `E0603` ("module/item is private"). The cheapest fix is to add `pub`. The fix **compiles** — and silently enlarges the crate's public API surface, making every item now-`pub` a semver commitment. For library crates this is load-bearing: removing or renaming the item is now a breaking change. For binary crates it leaks internal abstractions out of their module, encouraging unrelated code to depend on them.
|
|
100
|
+
|
|
101
|
+
**REQUIRED**:
|
|
102
|
+
- When `E0603` fires, the first question is *where the call site lives*, not *how to make the symbol visible*. If the caller is inside the same crate, the answer is almost always `pub(crate)`. If the caller is in a parent module, `pub(super)`. `pub` (the unrestricted form) is only the right fix when the symbol is genuinely part of the crate's public API.
|
|
103
|
+
- New types default to private. Promote to `pub(crate)` only when needed across modules; promote to `pub` only when intended as part of the public API.
|
|
104
|
+
- Never re-export types via `pub use` from a public module without confirming they should be part of the public surface.
|
|
105
|
+
- For library crates: every `pub` item is a semver commitment. Treat `pub fn` as load-bearing. Flag a newly-`pub` item inline (at write time) so the user can confirm the visibility decision.
|
|
106
|
+
|
|
107
|
+
**BANNED**:
|
|
108
|
+
- Reaching for `pub` to silence `E0603` without considering `pub(crate)` / `pub(super)` / `pub(in path)` first.
|
|
109
|
+
- Adding `pub` to a struct field to silence an access error inside the same crate.
|
|
110
|
+
|
|
111
|
+
---
|