Skip to main content

rch_common/testing/
injection.rs

1//! `RCH_*_INJECT_*` test-injection env-var registry (br-62u24.13 — MVP).
2//!
3//! This module is the **single source of truth** for the names of every
4//! debug/test injection environment variable consumed by RCH binaries.
5//! The bead spec for 62u24.13 calls out the foundation problem:
6//!
7//! > In the cross-agent code-review work (commits `68fcb7c` + d6d086e
8//! > and the 21 sibling beads in epics `2s99h`, `62u24`, `zcecy`), every
9//! > e2e test script and many unit tests reference debug-only env-var
10//! > hooks and `rch debug *` subcommands that don't exist in the
11//! > codebase yet. Without a foundational bead defining the contract
12//! > for these primitives, each task-bead implementer will arbitrarily
13//! > reinvent its own conventions — resulting in inconsistent naming,
14//! > duplicate functionality, missing `#[cfg(debug_assertions)]` gates,
15//! > and accidental release-build leakage.
16//!
17//! By defining the canonical names HERE as `pub const NAME_OF_VAR:
18//! &str = "RCH_..."` items, downstream tests can write
19//! `std::env::var(injection::RCH_DOCTOR_INJECT)` instead of
20//! `std::env::var("RCH_DOCTOR_INJECT")` — a typo in the latter is a
21//! silent no-op, but the former is a compile error.
22//!
23//! # Discipline (the contract this module enforces)
24//!
25//! 1. **All injection env-var names are `pub const NAME: &str` items
26//!    here.** Adding a new injection point requires adding the const,
27//!    not coining a new name at the consumer site.
28//! 2. **All names match the regex `RCH_[A-Z][A-Z0-9_]*_INJECT(_[A-Z0-9_]+)?`.**
29//!    The `RCH_` prefix is owned by us; the `_INJECT` segment is the
30//!    discriminator that lets release-build audits grep for "did any
31//!    injection name leak into the binary?".
32//! 3. **`ALL_INJECTION_VARS` enumerates every defined const.** The
33//!    unit tests at the bottom of this module assert (a) no duplicate
34//!    values, (b) every value matches the naming regex, (c) the slice
35//!    length equals the number of `pub const` items in this file.
36//!    Adding a new const without updating `ALL_INJECTION_VARS` is a
37//!    test failure.
38//! 4. **Release-build gate is the consumer's responsibility.** This
39//!    module exports names, not behavior. Each call site that reads
40//!    one of these env vars must gate the read with
41//!    `#[cfg(debug_assertions)]` (or document an explicit reason if a
42//!    release-build read is intentional). This module's name-only
43//!    surface is itself safe to compile in release.
44//!
45//! # Scope of this MVP delivery
46//!
47//! Lands the registry surface (names + enumeration + naming validator)
48//! that every other bead can immediately consume. The companion
49//! deliverables from 62u24.13 — `rch debug` subcommand convention,
50//! `request_id` propagation, tracing-capture test helper, the
51//! `#[cfg(debug_assertions)]` gate audit — are filed as follow-up
52//! scope. Each is independently substantial; the registry is the
53//! minimum that unblocks the rest.
54
55// =============================================================================
56// Doctor-domain injections
57// =============================================================================
58
59/// Inject a synthetic reliability-doctor diagnostic at the named code
60/// path. Value is a free-form discriminator (e.g.,
61/// `disk_pressure_warn`, `topology_circuit_open`) interpreted by the
62/// consumer; an empty/unset value disables the injection.
63pub const RCH_DOCTOR_INJECT: &str = "RCH_DOCTOR_INJECT";
64
65/// Inject a specific status-circuit state for a synthetic worker
66/// (e.g., `open_forced`). Used by reliability-doctor tests that need
67/// to exercise the partial-outage code path without spinning up a
68/// real fleet.
69pub const RCH_DOCTOR_INJECT_STATUS_CIRCUIT: &str = "RCH_DOCTOR_INJECT_STATUS_CIRCUIT";
70
71/// Inject artificial latency into a specific reliability probe. Value
72/// format: `<probe_name>=<delay_ms>` (e.g., `helpers=10000`). Used to
73/// exercise the per-probe timeout path (bd-62u24.8 parallelism).
74pub const RCH_DOCTOR_INJECT_SLOW_PROBE: &str = "RCH_DOCTOR_INJECT_SLOW_PROBE";
75
76// `RCH_DOCTOR_FIXTURE` and `RCH_DOCTOR_VERDICT` are STATE PINS, not
77// injections — they replace runtime state with a static value rather
78// than inserting a synthetic event into a live code path. They are
79// scoped to a sibling "fixture-pin registry" in a follow-up bead; the
80// naming-policy validator in this module would reject them anyway
81// (no `_INJECT` segment). Listing them here as a comment so a future
82// reader doesn't re-derive the same separation.
83
84// =============================================================================
85// Quick-check / status injections
86// =============================================================================
87
88/// Inject a synthetic stall on a specific worker's quick-check probe.
89/// Value is the worker ID (e.g., `w1`).
90pub const RCH_QC_INJECT_STALL: &str = "RCH_QC_INJECT_STALL";
91
92/// Force every worker's quick-check probe to stall (value `1`). Used
93/// by integration tests that need the "everything timing out" branch
94/// of the status surface.
95pub const RCH_QC_INJECT_STALL_ALL: &str = "RCH_QC_INJECT_STALL_ALL";
96
97// =============================================================================
98// Fix-mode injections (consumed by br-2s99h.12 --fix executor follow-up)
99// =============================================================================
100
101/// Inject a remediation step whose `dry_run_safe == false` so the
102/// `--fix` executor exercises the `SkippedNotSafe` outcome path
103/// without a real unsafe remediation in the registry.
104pub const RCH_FIX_INJECT_UNSAFE: &str = "RCH_FIX_INJECT_UNSAFE";
105
106/// Force the fix executor's post-validation step to fail even when
107/// remediation succeeded, so tests can exercise the
108/// `FailedPostValidation` outcome branch.
109pub const RCH_FIX_INJECT_POST_FAIL: &str = "RCH_FIX_INJECT_POST_FAIL";
110
111// =============================================================================
112// Debug / panic injections
113// =============================================================================
114
115/// Force the timing-cache save path to fail (value `1`) so tests can
116/// confirm the hook's fail-open behavior under disk-error conditions.
117///
118/// Naming note: the bead spec referenced this as
119/// `RCH_DEBUG_TIMING_SAVE_FAIL` (no `_INJECT` segment). The registry
120/// names it with `_INJECT_` per the canonical naming policy so a
121/// release-build leak audit can grep for `_INJECT` reliably. No
122/// existing consumer reads either name as of this commit, so no
123/// migration shim is needed.
124pub const RCH_DEBUG_INJECT_TIMING_SAVE_FAIL: &str = "RCH_DEBUG_INJECT_TIMING_SAVE_FAIL";
125
126/// Trigger an intentional panic at a designated code path (value
127/// `1`). Used by the hook fail-open regression suite to confirm that
128/// classifier panics are caught and the hook still exits 0 (the
129/// project's #1 invariant per AGENTS.md).
130///
131/// Naming note: bead spec used `RCH_PANIC_TEST`; canonical name uses
132/// `_INJECT_` per the registry's naming policy.
133pub const RCH_DEBUG_INJECT_PANIC: &str = "RCH_DEBUG_INJECT_PANIC";
134
135// =============================================================================
136// Registry & validators
137// =============================================================================
138
139/// Every injection env-var name registered in this module. Consumed
140/// by release-build leak audits, fuzzers, and the `rch debug` (TBD)
141/// subcommand's introspection surface.
142///
143/// **Discipline**: adding a new `pub const RCH_..._INJECT...: &str = "..."`
144/// requires appending its identifier here. The unit tests assert the
145/// slice length matches the number of `pub const` items, so a
146/// forgotten append is a test failure at `cargo test` time.
147pub const ALL_INJECTION_VARS: &[&str] = &[
148    RCH_DOCTOR_INJECT,
149    RCH_DOCTOR_INJECT_STATUS_CIRCUIT,
150    RCH_DOCTOR_INJECT_SLOW_PROBE,
151    RCH_QC_INJECT_STALL,
152    RCH_QC_INJECT_STALL_ALL,
153    RCH_FIX_INJECT_UNSAFE,
154    RCH_FIX_INJECT_POST_FAIL,
155    RCH_DEBUG_INJECT_TIMING_SAVE_FAIL,
156    RCH_DEBUG_INJECT_PANIC,
157];
158
159/// Returns `true` if `name` is one of the registered RCH injection
160/// env-var names. Used by audit tools that scan process environments
161/// for stray injection settings and by the (TBD) `rch debug
162/// list-injections` introspection surface.
163#[must_use]
164pub fn is_injection_var(name: &str) -> bool {
165    ALL_INJECTION_VARS.contains(&name)
166}
167
168/// Returns `true` if `name` is shape-compatible with the project's
169/// injection naming policy: starts with `RCH_`, contains `_INJECT`
170/// somewhere after the prefix, and consists only of ASCII uppercase /
171/// digits / underscores. Used by the unit tests below to enforce the
172/// naming regex without pulling in the `regex` crate (this is a
173/// `const`-friendly check on a small alphabet).
174#[must_use]
175pub fn is_well_formed_injection_name(name: &str) -> bool {
176    if !name.starts_with("RCH_") {
177        return false;
178    }
179    // Every character must be ASCII uppercase / digit / underscore.
180    if !name
181        .bytes()
182        .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
183    {
184        return false;
185    }
186    // `_INJECT` (or `_INJECT_<suffix>`) must appear somewhere after
187    // the `RCH_` prefix. Either ends with `_INJECT` or contains
188    // `_INJECT_`. We deliberately accept BOTH so the registry can
189    // include both shapes (`RCH_X_INJECT` and `RCH_X_INJECT_FOO`).
190    name.ends_with("_INJECT") || name.contains("_INJECT_")
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::collections::BTreeSet;
197
198    #[test]
199    fn every_registered_var_is_well_formed() {
200        // TEST START: enforces the naming regex on every registered const.
201        for name in ALL_INJECTION_VARS {
202            assert!(
203                is_well_formed_injection_name(name),
204                "registered injection var {name:?} violates the naming policy \
205                 (must start with RCH_, contain _INJECT, ASCII upper/digit/underscore only)"
206            );
207        }
208        // TEST PASS: every var well-formed
209    }
210
211    #[test]
212    fn registered_vars_have_no_duplicates() {
213        // TEST START: a copy-paste mistake in the const list could
214        // register the same name twice. BTreeSet round-trip catches it.
215        let set: BTreeSet<_> = ALL_INJECTION_VARS.iter().copied().collect();
216        assert_eq!(
217            set.len(),
218            ALL_INJECTION_VARS.len(),
219            "ALL_INJECTION_VARS contains duplicate entries: \
220             {} unique vs {} total",
221            set.len(),
222            ALL_INJECTION_VARS.len()
223        );
224    }
225
226    #[test]
227    fn is_injection_var_recognizes_every_registered_var() {
228        // TEST START: contract test on the public lookup helper.
229        for name in ALL_INJECTION_VARS {
230            assert!(
231                is_injection_var(name),
232                "is_injection_var({name:?}) returned false for a registered var"
233            );
234        }
235    }
236
237    #[test]
238    fn is_injection_var_rejects_unregistered_names() {
239        // TEST START: typo defense — `RCH_DOCTOR_INJEKT` (typo) must
240        // not be misclassified as a known injection. Catches a class
241        // of bug where a consumer reads `RCH_DOCTOR_INJEKT` and the
242        // audit tool greps for `RCH_DOCTOR_INJECT` and finds nothing,
243        // silently passing.
244        assert!(!is_injection_var("RCH_DOCTOR_INJEKT"));
245        assert!(!is_injection_var("RCH_NOT_A_REAL_VAR"));
246        assert!(!is_injection_var(""));
247        assert!(!is_injection_var("PATH"));
248    }
249
250    #[test]
251    fn well_formed_validator_rejects_obvious_bad_names() {
252        // TEST START: defensive validator for adding new vars.
253        assert!(!is_well_formed_injection_name("path"), "lowercase rejected");
254        assert!(
255            !is_well_formed_injection_name("RCH_DOCTOR"),
256            "missing _INJECT segment rejected"
257        );
258        assert!(
259            !is_well_formed_injection_name("rch_doctor_inject"),
260            "lowercase rejected (case-sensitive)"
261        );
262        assert!(
263            !is_well_formed_injection_name("FOO_BAR_INJECT"),
264            "missing RCH_ prefix rejected"
265        );
266        assert!(
267            !is_well_formed_injection_name("RCH_DOCTOR-INJECT"),
268            "hyphen rejected"
269        );
270        // Sanity: known-good shapes
271        assert!(is_well_formed_injection_name("RCH_X_INJECT"));
272        assert!(is_well_formed_injection_name("RCH_X_INJECT_FOO"));
273        assert!(is_well_formed_injection_name("RCH_X1_INJECT_Y2"));
274    }
275
276    #[test]
277    fn registry_slice_count_matches_module_consts() {
278        // TEST START: assert the slice length matches our expected count
279        // of `pub const` items. This is a brittle pin — its purpose is
280        // to remind a future contributor adding a const to ALSO append
281        // it to ALL_INJECTION_VARS. The number is the count of
282        // injection consts defined in this file (excluding the
283        // `ALL_INJECTION_VARS` slice itself).
284        //
285        // If you're updating this number, you almost certainly need to
286        // add a corresponding entry to ALL_INJECTION_VARS above.
287        const EXPECTED_REGISTERED_VAR_COUNT: usize = 9;
288        assert_eq!(
289            ALL_INJECTION_VARS.len(),
290            EXPECTED_REGISTERED_VAR_COUNT,
291            "ALL_INJECTION_VARS has {} entries but the test expects {}. \
292             Did you add a new `pub const RCH_..._INJECT...: &str` to this \
293             file without appending it to ALL_INJECTION_VARS, or did you \
294             remove one without updating this test?",
295            ALL_INJECTION_VARS.len(),
296            EXPECTED_REGISTERED_VAR_COUNT
297        );
298    }
299}