1use serde::{Deserialize, Serialize};
5
6fn default_classifier_timeout_ms() -> u64 {
7 5000
8}
9
10fn default_injection_model() -> String {
11 "protectai/deberta-v3-small-prompt-injection-v2".into()
12}
13
14fn default_injection_threshold() -> f32 {
15 0.95
16}
17
18fn default_injection_threshold_soft() -> f32 {
19 0.5
20}
21
22fn default_enforcement_mode() -> InjectionEnforcementMode {
23 InjectionEnforcementMode::Warn
24}
25
26fn default_pii_model() -> String {
27 "iiiorg/piiranha-v1-detect-personal-information".into()
28}
29
30fn default_pii_threshold() -> f32 {
31 0.75
32}
33
34fn default_pii_ner_max_chars() -> usize {
35 8192
36}
37
38fn default_pii_ner_circuit_breaker() -> u32 {
39 2
40}
41
42fn default_pii_ner_allowlist() -> Vec<String> {
43 vec![
44 "Zeph".into(),
45 "Rust".into(),
46 "OpenAI".into(),
47 "Ollama".into(),
48 "Claude".into(),
49 ]
50}
51
52fn default_three_class_threshold() -> f32 {
53 0.7
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
65#[serde(rename_all = "snake_case")]
66#[non_exhaustive]
67pub enum InjectionEnforcementMode {
68 Warn,
70 Block,
72}
73
74#[derive(Clone, PartialEq, Deserialize, Serialize)]
82pub struct ClassifiersConfig {
83 #[serde(default)]
85 pub enabled: bool,
86
87 #[serde(default = "default_classifier_timeout_ms")]
91 pub timeout_ms: u64,
92
93 #[serde(default)]
99 pub hf_token: Option<String>,
100
101 #[serde(default)]
107 pub scan_user_input: bool,
108
109 #[serde(default = "default_injection_model")]
111 pub injection_model: String,
112
113 #[serde(default = "default_enforcement_mode")]
122 pub enforcement_mode: InjectionEnforcementMode,
123
124 #[serde(
129 default = "default_injection_threshold_soft",
130 deserialize_with = "crate::de_helpers::de_unit_open"
131 )]
132 pub injection_threshold_soft: f32,
133
134 #[serde(
141 default = "default_injection_threshold",
142 deserialize_with = "crate::de_helpers::de_unit_open"
143 )]
144 pub injection_threshold: f32,
145
146 #[serde(default)]
151 pub injection_model_sha256: Option<String>,
152
153 #[serde(default)]
160 pub three_class_model: Option<String>,
161
162 #[serde(
167 default = "default_three_class_threshold",
168 deserialize_with = "crate::de_helpers::de_unit_open"
169 )]
170 pub three_class_threshold: f32,
171
172 #[serde(default)]
174 pub three_class_model_sha256: Option<String>,
175
176 #[serde(default)]
181 pub pii_enabled: bool,
182
183 #[serde(default = "default_pii_model")]
185 pub pii_model: String,
186
187 #[serde(default = "default_pii_threshold")]
193 pub pii_threshold: f32,
194
195 #[serde(default)]
197 pub pii_model_sha256: Option<String>,
198
199 #[serde(default = "default_pii_ner_max_chars")]
204 pub pii_ner_max_chars: usize,
205
206 #[serde(default = "default_pii_ner_allowlist")]
216 pub pii_ner_allowlist: Vec<String>,
217
218 #[serde(default = "default_pii_ner_circuit_breaker")]
227 pub pii_ner_circuit_breaker: u32,
228}
229
230impl Default for ClassifiersConfig {
231 fn default() -> Self {
232 Self {
233 enabled: false,
234 timeout_ms: default_classifier_timeout_ms(),
235 hf_token: None,
236 scan_user_input: false,
237 injection_model: default_injection_model(),
238 enforcement_mode: default_enforcement_mode(),
239 injection_threshold_soft: default_injection_threshold_soft(),
240 injection_threshold: default_injection_threshold(),
241 injection_model_sha256: None,
242 three_class_model: None,
243 three_class_threshold: default_three_class_threshold(),
244 three_class_model_sha256: None,
245 pii_enabled: false,
246 pii_model: default_pii_model(),
247 pii_threshold: default_pii_threshold(),
248 pii_model_sha256: None,
249 pii_ner_max_chars: default_pii_ner_max_chars(),
250 pii_ner_allowlist: default_pii_ner_allowlist(),
251 pii_ner_circuit_breaker: default_pii_ner_circuit_breaker(),
252 }
253 }
254}
255
256impl std::fmt::Debug for ClassifiersConfig {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 f.debug_struct("ClassifiersConfig")
259 .field("enabled", &self.enabled)
260 .field("timeout_ms", &self.timeout_ms)
261 .field("hf_token", &self.hf_token.as_ref().map(|_| "[REDACTED]"))
262 .field("scan_user_input", &self.scan_user_input)
263 .field("injection_model", &self.injection_model)
264 .field("enforcement_mode", &self.enforcement_mode)
265 .field("injection_threshold_soft", &self.injection_threshold_soft)
266 .field("injection_threshold", &self.injection_threshold)
267 .field("injection_model_sha256", &self.injection_model_sha256)
268 .field("three_class_model", &self.three_class_model)
269 .field("three_class_threshold", &self.three_class_threshold)
270 .field("three_class_model_sha256", &self.three_class_model_sha256)
271 .field("pii_enabled", &self.pii_enabled)
272 .field("pii_model", &self.pii_model)
273 .field("pii_threshold", &self.pii_threshold)
274 .field("pii_model_sha256", &self.pii_model_sha256)
275 .field("pii_ner_max_chars", &self.pii_ner_max_chars)
276 .field("pii_ner_allowlist", &self.pii_ner_allowlist)
277 .field("pii_ner_circuit_breaker", &self.pii_ner_circuit_breaker)
278 .finish()
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn default_values() {
288 let cfg = ClassifiersConfig::default();
289 assert!(!cfg.enabled);
290 assert_eq!(cfg.timeout_ms, 5000);
291 assert!(cfg.hf_token.is_none());
292 assert!(!cfg.scan_user_input);
293 assert_eq!(
294 cfg.injection_model,
295 "protectai/deberta-v3-small-prompt-injection-v2"
296 );
297 assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Warn);
298 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
299 assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
300 assert!(cfg.injection_model_sha256.is_none());
301 assert!(cfg.three_class_model.is_none());
302 assert!((cfg.three_class_threshold - 0.7).abs() < 1e-6);
303 assert!(cfg.three_class_model_sha256.is_none());
304 assert!(!cfg.pii_enabled);
305 assert_eq!(
306 cfg.pii_model,
307 "iiiorg/piiranha-v1-detect-personal-information"
308 );
309 assert!((cfg.pii_threshold - 0.75).abs() < 1e-6);
310 assert!(cfg.pii_model_sha256.is_none());
311 assert_eq!(
312 cfg.pii_ner_allowlist,
313 vec!["Zeph", "Rust", "OpenAI", "Ollama", "Claude"]
314 );
315 }
316
317 #[test]
318 fn hf_token_and_scan_user_input_round_trip() {
319 let toml = r#"
320 hf_token = "hf_secret"
321 scan_user_input = true
322 "#;
323 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
324 assert_eq!(cfg.hf_token.as_deref(), Some("hf_secret"));
325 assert!(cfg.scan_user_input);
326 }
327
328 #[test]
329 fn deserialize_empty_section_uses_defaults() {
330 let cfg: ClassifiersConfig = toml::from_str("").unwrap();
331 assert!(!cfg.enabled);
332 assert_eq!(cfg.timeout_ms, 5000);
333 assert_eq!(
334 cfg.injection_model,
335 "protectai/deberta-v3-small-prompt-injection-v2"
336 );
337 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
338 assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
339 assert!(!cfg.pii_enabled);
340 assert!((cfg.pii_threshold - 0.75).abs() < 1e-6);
341 }
342
343 #[test]
344 fn deserialize_custom_values() {
345 let toml = r#"
346 enabled = true
347 timeout_ms = 2000
348 injection_model = "custom/model-v1"
349 injection_threshold = 0.9
350 pii_enabled = true
351 pii_threshold = 0.85
352 "#;
353 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
354 assert!(cfg.enabled);
355 assert_eq!(cfg.timeout_ms, 2000);
356 assert_eq!(cfg.injection_model, "custom/model-v1");
357 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
358 assert!((cfg.injection_threshold - 0.9).abs() < 1e-6);
359 assert!(cfg.pii_enabled);
360 assert!((cfg.pii_threshold - 0.85).abs() < 1e-6);
361 }
362
363 #[test]
364 fn deserialize_sha256_fields() {
365 let toml = r#"
366 injection_model_sha256 = "abc123"
367 pii_model_sha256 = "def456"
368 "#;
369 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
370 assert_eq!(cfg.injection_model_sha256.as_deref(), Some("abc123"));
371 assert_eq!(cfg.pii_model_sha256.as_deref(), Some("def456"));
372 }
373
374 #[test]
375 fn serialize_roundtrip() {
376 let original = ClassifiersConfig {
377 enabled: true,
378 timeout_ms: 3000,
379 hf_token: Some("hf_test_token".into()),
380 scan_user_input: true,
381 injection_model: "org/model".into(),
382 enforcement_mode: InjectionEnforcementMode::Block,
383 injection_threshold_soft: 0.45,
384 injection_threshold: 0.75,
385 injection_model_sha256: Some("deadbeef".into()),
386 three_class_model: Some("org/three-class".into()),
387 three_class_threshold: 0.65,
388 three_class_model_sha256: Some("abc456".into()),
389 pii_enabled: true,
390 pii_model: "org/pii-model".into(),
391 pii_threshold: 0.80,
392 pii_model_sha256: None,
393 pii_ner_max_chars: 4096,
394 pii_ner_allowlist: vec!["MyProject".into(), "Rust".into()],
395 pii_ner_circuit_breaker: 3,
396 };
397 let serialized = toml::to_string(&original).unwrap();
398 let deserialized: ClassifiersConfig = toml::from_str(&serialized).unwrap();
399 assert_eq!(original, deserialized);
400 }
401
402 #[test]
403 fn dual_threshold_deserialization() {
404 let toml = r"
405 injection_threshold_soft = 0.4
406 injection_threshold = 0.85
407 ";
408 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
409 assert!((cfg.injection_threshold_soft - 0.4).abs() < 1e-6);
410 assert!((cfg.injection_threshold - 0.85).abs() < 1e-6);
411 }
412
413 #[test]
414 fn soft_threshold_defaults_when_only_hard_provided() {
415 let toml = "injection_threshold = 0.9";
416 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
417 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
418 assert!((cfg.injection_threshold - 0.9).abs() < 1e-6);
419 }
420
421 #[test]
422 fn partial_override_timeout_only() {
423 let toml = "timeout_ms = 1000";
424 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
425 assert!(!cfg.enabled);
426 assert_eq!(cfg.timeout_ms, 1000);
427 assert_eq!(
428 cfg.injection_model,
429 "protectai/deberta-v3-small-prompt-injection-v2"
430 );
431 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
432 assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
433 }
434
435 #[test]
436 fn enforcement_mode_warn_is_default() {
437 let cfg: ClassifiersConfig = toml::from_str("").unwrap();
438 assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Warn);
439 }
440
441 #[test]
442 fn enforcement_mode_block_roundtrip() {
443 let toml = r#"enforcement_mode = "block""#;
444 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
445 assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Block);
446 let back = toml::to_string(&cfg).unwrap();
447 let cfg2: ClassifiersConfig = toml::from_str(&back).unwrap();
448 assert_eq!(cfg2.enforcement_mode, InjectionEnforcementMode::Block);
449 }
450
451 #[test]
452 fn threshold_validation_rejects_zero() {
453 let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold = 0.0");
454 assert!(result.is_err());
455 }
456
457 #[test]
458 fn threshold_validation_rejects_above_one() {
459 let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold = 1.1");
460 assert!(result.is_err());
461 }
462
463 #[test]
464 fn threshold_validation_accepts_exactly_one() {
465 let cfg: ClassifiersConfig = toml::from_str("injection_threshold = 1.0").unwrap();
466 assert!((cfg.injection_threshold - 1.0).abs() < 1e-6);
467 }
468
469 #[test]
470 fn threshold_validation_soft_rejects_zero() {
471 let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold_soft = 0.0");
472 assert!(result.is_err());
473 }
474
475 #[test]
476 fn three_class_model_roundtrip() {
477 let toml = r#"
478 three_class_model = "org/align-sentinel"
479 three_class_threshold = 0.65
480 three_class_model_sha256 = "aabbcc"
481 "#;
482 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
483 assert_eq!(cfg.three_class_model.as_deref(), Some("org/align-sentinel"));
484 assert!((cfg.three_class_threshold - 0.65).abs() < 1e-6);
485 assert_eq!(cfg.three_class_model_sha256.as_deref(), Some("aabbcc"));
486 }
487
488 #[test]
489 fn pii_ner_allowlist_default_entries() {
490 let cfg = ClassifiersConfig::default();
491 assert!(cfg.pii_ner_allowlist.contains(&"Zeph".to_owned()));
492 assert!(cfg.pii_ner_allowlist.contains(&"Rust".to_owned()));
493 assert!(cfg.pii_ner_allowlist.contains(&"OpenAI".to_owned()));
494 assert!(cfg.pii_ner_allowlist.contains(&"Ollama".to_owned()));
495 assert!(cfg.pii_ner_allowlist.contains(&"Claude".to_owned()));
496 }
497
498 #[test]
499 fn pii_ner_allowlist_configurable() {
500 let toml = r#"pii_ner_allowlist = ["MyProject", "AcmeCorp"]"#;
501 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
502 assert_eq!(cfg.pii_ner_allowlist, vec!["MyProject", "AcmeCorp"]);
503 }
504
505 #[test]
506 fn pii_ner_allowlist_empty_disables() {
507 let toml = "pii_ner_allowlist = []";
508 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
509 assert!(cfg.pii_ner_allowlist.is_empty());
510 }
511
512 #[test]
513 fn three_class_threshold_validation_rejects_zero() {
514 let result: Result<ClassifiersConfig, _> = toml::from_str("three_class_threshold = 0.0");
515 assert!(result.is_err());
516 }
517
518 #[test]
519 fn pii_ner_circuit_breaker_default() {
520 let cfg = ClassifiersConfig::default();
521 assert_eq!(cfg.pii_ner_circuit_breaker, 2);
522 }
523
524 #[test]
525 fn pii_ner_circuit_breaker_configurable() {
526 let cfg: ClassifiersConfig = toml::from_str("pii_ner_circuit_breaker = 5").unwrap();
527 assert_eq!(cfg.pii_ner_circuit_breaker, 5);
528 }
529
530 #[test]
531 fn pii_ner_circuit_breaker_zero_disables() {
532 let cfg: ClassifiersConfig = toml::from_str("pii_ner_circuit_breaker = 0").unwrap();
533 assert_eq!(cfg.pii_ner_circuit_breaker, 0);
534 }
535
536 #[test]
537 fn pii_ner_circuit_breaker_missing_uses_default() {
538 let cfg: ClassifiersConfig = toml::from_str("").unwrap();
539 assert_eq!(cfg.pii_ner_circuit_breaker, 2);
540 }
541
542 #[test]
543 fn classifiers_config_debug_redacts_hf_token() {
544 let cfg = ClassifiersConfig {
545 hf_token: Some("hf_SUPERSECRET".to_owned()),
546 ..ClassifiersConfig::default()
547 };
548 let dbg = format!("{cfg:?}");
549 assert!(!dbg.contains("hf_SUPERSECRET"));
550 assert!(dbg.contains("[REDACTED]"));
551 }
552
553 #[test]
554 fn classifiers_config_debug_none_hf_token() {
555 let cfg = ClassifiersConfig::default();
556 let dbg = format!("{cfg:?}");
557 assert!(!dbg.contains("[REDACTED]"));
558 assert!(dbg.contains("hf_token: None"));
559 }
560}