1use std::{
4 cell::RefCell,
5 collections::{BTreeMap, VecDeque},
6 rc::Rc,
7};
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum ScenarioBoundary {
16 BeforeOperation,
18 AfterDurableCommit,
20 BeforeResponse,
22 DuringResponse,
24 Timeout,
26 Cancellation,
28 DroppedConnection,
30 Cleanup,
32 ResourceAcquire,
34}
35
36impl ScenarioBoundary {
37 const fn label(self) -> &'static str {
38 match self {
39 Self::BeforeOperation => "before-operation",
40 Self::AfterDurableCommit => "after-durable-commit",
41 Self::BeforeResponse => "before-response",
42 Self::DuringResponse => "during-response",
43 Self::Timeout => "timeout",
44 Self::Cancellation => "cancellation",
45 Self::DroppedConnection => "dropped-connection",
46 Self::Cleanup => "cleanup",
47 Self::ResourceAcquire => "resource-acquire",
48 }
49 }
50}
51
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum SimulatorFault {
59 Timeout,
61 Cancellation,
63 DroppedConnection,
65 CleanupFailure,
67 ResourceUnavailable,
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub struct FaultPointError;
74
75impl std::fmt::Display for FaultPointError {
76 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 formatter.write_str("fault point must be a bounded ASCII scenario label")
78 }
79}
80
81impl std::error::Error for FaultPointError {}
82
83#[derive(Clone, Debug, Default)]
88pub struct FaultInjector {
89 queues: Rc<RefCell<BTreeMap<String, VecDeque<SimulatorFault>>>>,
90}
91
92impl FaultInjector {
93 pub fn new() -> Self {
95 Self::default()
96 }
97
98 pub fn inject(&self, point: &str, fault: SimulatorFault) -> Result<(), FaultPointError> {
100 validate_point(point)?;
101 self.queues
102 .borrow_mut()
103 .entry(point.to_owned())
104 .or_default()
105 .push_back(fault);
106 Ok(())
107 }
108
109 pub fn inject_at(
111 &self,
112 operation: &str,
113 boundary: ScenarioBoundary,
114 fault: SimulatorFault,
115 ) -> Result<(), FaultPointError> {
116 self.inject(&format!("{operation}.{}", boundary.label()), fault)
117 }
118
119 pub fn check(&self, point: &str) -> Result<Result<(), SimulatorFault>, FaultPointError> {
121 validate_point(point)?;
122 let mut queues = self.queues.borrow_mut();
123 let fault = queues.get_mut(point).and_then(VecDeque::pop_front);
124 if queues.get(point).is_some_and(VecDeque::is_empty) {
125 queues.remove(point);
126 }
127 Ok(fault.map_or(Ok(()), Err))
128 }
129
130 pub fn check_at(
132 &self,
133 operation: &str,
134 boundary: ScenarioBoundary,
135 ) -> Result<Result<(), SimulatorFault>, FaultPointError> {
136 self.check(&format!("{operation}.{}", boundary.label()))
137 }
138
139 pub fn has_pending(&self, point: &str) -> Result<bool, FaultPointError> {
141 validate_point(point)?;
142 Ok(self
143 .queues
144 .borrow()
145 .get(point)
146 .is_some_and(|queue| !queue.is_empty()))
147 }
148}
149
150pub(crate) fn validate_point(point: &str) -> Result<(), FaultPointError> {
151 if point.is_empty()
152 || point.len() > 128
153 || !point
154 .bytes()
155 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'))
156 {
157 return Err(FaultPointError);
158 }
159 Ok(())
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn faults_are_consumed_once_in_test_selected_order() {
168 let faults = FaultInjector::new();
169 faults
170 .inject(
171 "oauth.after-durable-commit",
172 SimulatorFault::DroppedConnection,
173 )
174 .unwrap();
175 faults
176 .inject("oauth.after-durable-commit", SimulatorFault::Timeout)
177 .unwrap();
178
179 assert!(faults.has_pending("oauth.after-durable-commit").unwrap());
180 assert_eq!(
181 faults.check("oauth.after-durable-commit").unwrap(),
182 Err(SimulatorFault::DroppedConnection)
183 );
184 assert_eq!(
185 faults.check("oauth.after-durable-commit").unwrap(),
186 Err(SimulatorFault::Timeout)
187 );
188 assert_eq!(faults.check("oauth.after-durable-commit").unwrap(), Ok(()));
189 assert!(!faults.has_pending("oauth.after-durable-commit").unwrap());
190 }
191
192 #[test]
193 fn canonical_boundaries_do_not_hide_the_operation_identity() {
194 let faults = FaultInjector::new();
195 faults
196 .inject_at(
197 "oauth.consume",
198 ScenarioBoundary::AfterDurableCommit,
199 SimulatorFault::DroppedConnection,
200 )
201 .unwrap();
202
203 assert_eq!(
204 faults
205 .check_at("oauth.consume", ScenarioBoundary::AfterDurableCommit)
206 .unwrap(),
207 Err(SimulatorFault::DroppedConnection)
208 );
209 assert_eq!(
210 faults
211 .check_at("oauth.create", ScenarioBoundary::AfterDurableCommit)
212 .unwrap(),
213 Ok(())
214 );
215 }
216
217 #[test]
218 fn fault_points_reject_unbounded_or_sensitive_shape() {
219 let faults = FaultInjector::new();
220
221 assert_eq!(
222 faults.inject("before response", SimulatorFault::Timeout),
223 Err(FaultPointError)
224 );
225 assert_eq!(faults.check(""), Err(FaultPointError));
226 }
227}