stygian_charon/challenge_feedback/
key.rs1use std::fmt::{self, Display, Formatter};
42use std::str::FromStr;
43
44use serde::{Deserialize, Serialize};
45
46use crate::types::TargetClass;
47use crate::vendor_classifier::VendorId;
48
49#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
52pub enum EngineKeyParseError {
53 #[error("engine key is empty")]
55 Empty,
56 #[error("engine key is missing ':' separator between engine and target class")]
60 MissingTargetClassSeparator,
61 #[error("unknown engine label: {0}")]
63 UnknownEngine(String),
64 #[error("unknown target class label: {0}")]
67 UnknownTargetClass(String),
68 #[error("engine key has empty {0} component")]
71 EmptyModifier(&'static str),
72}
73
74#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
105pub struct EngineKey {
106 pub engine: VendorId,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub version: Option<String>,
113 pub target_class: TargetClass,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub tls_profile: Option<String>,
119}
120
121impl EngineKey {
122 #[must_use]
125 pub const fn engine_label(&self) -> &'static str {
126 self.engine.label()
127 }
128
129 pub fn validate(&self) -> Result<(), EngineKeyParseError> {
141 if let Some(version) = self.version.as_deref()
142 && version.is_empty()
143 {
144 return Err(EngineKeyParseError::EmptyModifier("version"));
145 }
146 if let Some(tls_profile) = self.tls_profile.as_deref()
147 && tls_profile.is_empty()
148 {
149 return Err(EngineKeyParseError::EmptyModifier("tls_profile"));
150 }
151 Ok(())
152 }
153}
154
155impl Display for EngineKey {
156 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
157 write!(f, "{}", self.engine.label())?;
158 if let Some(version) = self.version.as_deref() {
159 write!(f, "/{version}")?;
160 }
161 if let Some(tls_profile) = self.tls_profile.as_deref() {
162 write!(f, "+{tls_profile}")?;
163 }
164 let target_class = match self.target_class {
165 TargetClass::Api => "api",
166 TargetClass::ContentSite => "content_site",
167 TargetClass::HighSecurity => "high_security",
168 TargetClass::Unknown => "unknown",
169 };
170 write!(f, ":{target_class}")
171 }
172}
173
174impl FromStr for EngineKey {
175 type Err = EngineKeyParseError;
176
177 fn from_str(s: &str) -> Result<Self, Self::Err> {
178 if s.is_empty() {
179 return Err(EngineKeyParseError::Empty);
180 }
181
182 let (head, target_class_str) = s
183 .rsplit_once(':')
184 .ok_or(EngineKeyParseError::MissingTargetClassSeparator)?;
185 let target_class = match target_class_str {
186 "api" => TargetClass::Api,
187 "content_site" => TargetClass::ContentSite,
188 "high_security" => TargetClass::HighSecurity,
189 "unknown" => TargetClass::Unknown,
190 other => return Err(EngineKeyParseError::UnknownTargetClass(other.to_string())),
191 };
192
193 let mut version: Option<String> = None;
195 let mut tls_profile: Option<String> = None;
196 let engine_str;
197
198 if let Some((pre_tls, tls)) = head.split_once('+') {
199 if tls.is_empty() {
200 return Err(EngineKeyParseError::EmptyModifier("tls_profile"));
201 }
202 tls_profile = Some(tls.to_string());
203 if let Some((pre_ver, ver)) = pre_tls.split_once('/') {
204 if ver.is_empty() {
205 return Err(EngineKeyParseError::EmptyModifier("version"));
206 }
207 version = Some(ver.to_string());
208 engine_str = pre_ver;
209 } else {
210 engine_str = pre_tls;
211 }
212 } else if let Some((pre_ver, ver)) = head.split_once('/') {
213 if ver.is_empty() {
214 return Err(EngineKeyParseError::EmptyModifier("version"));
215 }
216 version = Some(ver.to_string());
217 engine_str = pre_ver;
218 } else {
219 engine_str = head;
220 }
221
222 let engine = VendorId::from_label(engine_str)
223 .ok_or_else(|| EngineKeyParseError::UnknownEngine(engine_str.to_string()))?;
224
225 Ok(Self {
226 engine,
227 version,
228 target_class,
229 tls_profile,
230 })
231 }
232}
233
234#[cfg(test)]
235#[allow(
236 clippy::unwrap_used,
237 clippy::expect_used,
238 clippy::panic,
239 clippy::indexing_slicing
240)]
241mod tests {
242 use super::*;
243
244 fn cloudflare_api() -> EngineKey {
245 EngineKey {
246 engine: VendorId::Cloudflare,
247 version: None,
248 target_class: TargetClass::Api,
249 tls_profile: None,
250 }
251 }
252
253 #[test]
254 fn display_round_trips_through_from_str() {
255 for key in [
256 cloudflare_api(),
257 EngineKey {
258 engine: VendorId::Akamai,
259 version: Some("bot-manager-v3".to_string()),
260 target_class: TargetClass::HighSecurity,
261 tls_profile: None,
262 },
263 EngineKey {
264 engine: VendorId::Cloudflare,
265 version: None,
266 target_class: TargetClass::ContentSite,
267 tls_profile: Some("chrome136".to_string()),
268 },
269 EngineKey {
270 engine: VendorId::DataDome,
271 version: Some("v2".to_string()),
272 target_class: TargetClass::Api,
273 tls_profile: Some("firefox130".to_string()),
274 },
275 ] {
276 let rendered = key.to_string();
277 let parsed: EngineKey = rendered.parse().expect("round-trip parse");
278 assert_eq!(parsed, key, "round-trip mismatch for {rendered}");
279 }
280 }
281
282 #[test]
283 fn from_str_rejects_empty_input() {
284 assert_eq!(
285 EngineKey::from_str(""),
286 Err(EngineKeyParseError::Empty),
287 "empty input must be rejected"
288 );
289 }
290
291 #[test]
292 fn from_str_rejects_missing_target_class_separator() {
293 assert_eq!(
294 EngineKey::from_str("cloudflare"),
295 Err(EngineKeyParseError::MissingTargetClassSeparator),
296 "missing ':' separator must be rejected"
297 );
298 }
299
300 #[test]
301 fn from_str_rejects_unknown_engine_label() {
302 assert_eq!(
303 EngineKey::from_str("not_a_vendor:api"),
304 Err(EngineKeyParseError::UnknownEngine(
305 "not_a_vendor".to_string()
306 )),
307 "unknown engine label must be rejected"
308 );
309 }
310
311 #[test]
312 fn from_str_rejects_unknown_target_class_label() {
313 assert_eq!(
314 EngineKey::from_str("cloudflare:not_a_class"),
315 Err(EngineKeyParseError::UnknownTargetClass(
316 "not_a_class".to_string()
317 )),
318 "unknown target class must be rejected"
319 );
320 }
321
322 #[test]
323 fn from_str_rejects_empty_modifier_components() {
324 assert_eq!(
325 EngineKey::from_str("cloudflare/:api"),
326 Err(EngineKeyParseError::EmptyModifier("version")),
327 "empty version must be rejected"
328 );
329 assert_eq!(
330 EngineKey::from_str("cloudflare+:api"),
331 Err(EngineKeyParseError::EmptyModifier("tls_profile")),
332 "empty tls_profile must be rejected"
333 );
334 }
335
336 #[test]
337 fn keys_differing_only_by_target_class_are_not_equal() {
338 let a = cloudflare_api();
339 let b = EngineKey {
340 target_class: TargetClass::ContentSite,
341 ..a.clone()
342 };
343 assert_ne!(a, b, "target_class must participate in equality");
344 }
345
346 #[test]
347 fn keys_differing_only_by_tls_profile_are_not_equal() {
348 let a = cloudflare_api();
349 let b = EngineKey {
350 tls_profile: Some("chrome136".to_string()),
351 ..a.clone()
352 };
353 assert_ne!(a, b, "tls_profile must participate in equality");
354 }
355
356 #[test]
357 fn keys_differing_only_by_version_are_not_equal() {
358 let a = EngineKey {
359 version: Some("v3".to_string()),
360 ..cloudflare_api()
361 };
362 let b = EngineKey {
363 version: Some("v4".to_string()),
364 ..a.clone()
365 };
366 assert_ne!(a, b, "version must participate in equality");
367 }
368
369 #[test]
370 fn serde_json_round_trips_through_engine_key() {
371 let key = EngineKey {
372 engine: VendorId::PerimeterX,
373 version: Some("human-v1".to_string()),
374 target_class: TargetClass::HighSecurity,
375 tls_profile: Some("chrome136".to_string()),
376 };
377 let json = serde_json::to_string(&key).expect("serialize");
378 let parsed: EngineKey = serde_json::from_str(&json).expect("deserialize");
379 assert_eq!(parsed, key, "JSON round-trip mismatch");
380 }
381
382 #[test]
383 fn from_str_uses_rsplit_once_to_locate_target_class() {
384 match EngineKey::from_str("cloudflare:api:extra") {
390 Err(EngineKeyParseError::UnknownTargetClass(s)) => assert_eq!(s, "extra"),
391 other => panic!(
392 "extra colons must surface as UnknownTargetClass on the suffix, got {other:?}"
393 ),
394 }
395
396 assert_eq!(
399 EngineKey::from_str("cloudflare:"),
400 Err(EngineKeyParseError::UnknownTargetClass(String::new()))
401 );
402 }
403
404 #[test]
405 fn validate_rejects_empty_optional_components() {
406 let bad_version = EngineKey {
407 version: Some(String::new()),
408 ..cloudflare_api()
409 };
410 assert_eq!(
411 bad_version.validate(),
412 Err(EngineKeyParseError::EmptyModifier("version"))
413 );
414
415 let bad_tls = EngineKey {
416 tls_profile: Some(String::new()),
417 ..cloudflare_api()
418 };
419 assert_eq!(
420 bad_tls.validate(),
421 Err(EngineKeyParseError::EmptyModifier("tls_profile"))
422 );
423
424 assert_eq!(cloudflare_api().validate(), Ok(()));
425 }
426}