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(Debug, 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
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn default_values() {
262 let cfg = ClassifiersConfig::default();
263 assert!(!cfg.enabled);
264 assert_eq!(cfg.timeout_ms, 5000);
265 assert!(cfg.hf_token.is_none());
266 assert!(!cfg.scan_user_input);
267 assert_eq!(
268 cfg.injection_model,
269 "protectai/deberta-v3-small-prompt-injection-v2"
270 );
271 assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Warn);
272 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
273 assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
274 assert!(cfg.injection_model_sha256.is_none());
275 assert!(cfg.three_class_model.is_none());
276 assert!((cfg.three_class_threshold - 0.7).abs() < 1e-6);
277 assert!(cfg.three_class_model_sha256.is_none());
278 assert!(!cfg.pii_enabled);
279 assert_eq!(
280 cfg.pii_model,
281 "iiiorg/piiranha-v1-detect-personal-information"
282 );
283 assert!((cfg.pii_threshold - 0.75).abs() < 1e-6);
284 assert!(cfg.pii_model_sha256.is_none());
285 assert_eq!(
286 cfg.pii_ner_allowlist,
287 vec!["Zeph", "Rust", "OpenAI", "Ollama", "Claude"]
288 );
289 }
290
291 #[test]
292 fn hf_token_and_scan_user_input_round_trip() {
293 let toml = r#"
294 hf_token = "hf_secret"
295 scan_user_input = true
296 "#;
297 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
298 assert_eq!(cfg.hf_token.as_deref(), Some("hf_secret"));
299 assert!(cfg.scan_user_input);
300 }
301
302 #[test]
303 fn deserialize_empty_section_uses_defaults() {
304 let cfg: ClassifiersConfig = toml::from_str("").unwrap();
305 assert!(!cfg.enabled);
306 assert_eq!(cfg.timeout_ms, 5000);
307 assert_eq!(
308 cfg.injection_model,
309 "protectai/deberta-v3-small-prompt-injection-v2"
310 );
311 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
312 assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
313 assert!(!cfg.pii_enabled);
314 assert!((cfg.pii_threshold - 0.75).abs() < 1e-6);
315 }
316
317 #[test]
318 fn deserialize_custom_values() {
319 let toml = r#"
320 enabled = true
321 timeout_ms = 2000
322 injection_model = "custom/model-v1"
323 injection_threshold = 0.9
324 pii_enabled = true
325 pii_threshold = 0.85
326 "#;
327 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
328 assert!(cfg.enabled);
329 assert_eq!(cfg.timeout_ms, 2000);
330 assert_eq!(cfg.injection_model, "custom/model-v1");
331 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
332 assert!((cfg.injection_threshold - 0.9).abs() < 1e-6);
333 assert!(cfg.pii_enabled);
334 assert!((cfg.pii_threshold - 0.85).abs() < 1e-6);
335 }
336
337 #[test]
338 fn deserialize_sha256_fields() {
339 let toml = r#"
340 injection_model_sha256 = "abc123"
341 pii_model_sha256 = "def456"
342 "#;
343 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
344 assert_eq!(cfg.injection_model_sha256.as_deref(), Some("abc123"));
345 assert_eq!(cfg.pii_model_sha256.as_deref(), Some("def456"));
346 }
347
348 #[test]
349 fn serialize_roundtrip() {
350 let original = ClassifiersConfig {
351 enabled: true,
352 timeout_ms: 3000,
353 hf_token: Some("hf_test_token".into()),
354 scan_user_input: true,
355 injection_model: "org/model".into(),
356 enforcement_mode: InjectionEnforcementMode::Block,
357 injection_threshold_soft: 0.45,
358 injection_threshold: 0.75,
359 injection_model_sha256: Some("deadbeef".into()),
360 three_class_model: Some("org/three-class".into()),
361 three_class_threshold: 0.65,
362 three_class_model_sha256: Some("abc456".into()),
363 pii_enabled: true,
364 pii_model: "org/pii-model".into(),
365 pii_threshold: 0.80,
366 pii_model_sha256: None,
367 pii_ner_max_chars: 4096,
368 pii_ner_allowlist: vec!["MyProject".into(), "Rust".into()],
369 pii_ner_circuit_breaker: 3,
370 };
371 let serialized = toml::to_string(&original).unwrap();
372 let deserialized: ClassifiersConfig = toml::from_str(&serialized).unwrap();
373 assert_eq!(original, deserialized);
374 }
375
376 #[test]
377 fn dual_threshold_deserialization() {
378 let toml = r"
379 injection_threshold_soft = 0.4
380 injection_threshold = 0.85
381 ";
382 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
383 assert!((cfg.injection_threshold_soft - 0.4).abs() < 1e-6);
384 assert!((cfg.injection_threshold - 0.85).abs() < 1e-6);
385 }
386
387 #[test]
388 fn soft_threshold_defaults_when_only_hard_provided() {
389 let toml = "injection_threshold = 0.9";
390 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
391 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
392 assert!((cfg.injection_threshold - 0.9).abs() < 1e-6);
393 }
394
395 #[test]
396 fn partial_override_timeout_only() {
397 let toml = "timeout_ms = 1000";
398 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
399 assert!(!cfg.enabled);
400 assert_eq!(cfg.timeout_ms, 1000);
401 assert_eq!(
402 cfg.injection_model,
403 "protectai/deberta-v3-small-prompt-injection-v2"
404 );
405 assert!((cfg.injection_threshold_soft - 0.5).abs() < 1e-6);
406 assert!((cfg.injection_threshold - 0.95).abs() < 1e-6);
407 }
408
409 #[test]
410 fn enforcement_mode_warn_is_default() {
411 let cfg: ClassifiersConfig = toml::from_str("").unwrap();
412 assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Warn);
413 }
414
415 #[test]
416 fn enforcement_mode_block_roundtrip() {
417 let toml = r#"enforcement_mode = "block""#;
418 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
419 assert_eq!(cfg.enforcement_mode, InjectionEnforcementMode::Block);
420 let back = toml::to_string(&cfg).unwrap();
421 let cfg2: ClassifiersConfig = toml::from_str(&back).unwrap();
422 assert_eq!(cfg2.enforcement_mode, InjectionEnforcementMode::Block);
423 }
424
425 #[test]
426 fn threshold_validation_rejects_zero() {
427 let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold = 0.0");
428 assert!(result.is_err());
429 }
430
431 #[test]
432 fn threshold_validation_rejects_above_one() {
433 let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold = 1.1");
434 assert!(result.is_err());
435 }
436
437 #[test]
438 fn threshold_validation_accepts_exactly_one() {
439 let cfg: ClassifiersConfig = toml::from_str("injection_threshold = 1.0").unwrap();
440 assert!((cfg.injection_threshold - 1.0).abs() < 1e-6);
441 }
442
443 #[test]
444 fn threshold_validation_soft_rejects_zero() {
445 let result: Result<ClassifiersConfig, _> = toml::from_str("injection_threshold_soft = 0.0");
446 assert!(result.is_err());
447 }
448
449 #[test]
450 fn three_class_model_roundtrip() {
451 let toml = r#"
452 three_class_model = "org/align-sentinel"
453 three_class_threshold = 0.65
454 three_class_model_sha256 = "aabbcc"
455 "#;
456 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
457 assert_eq!(cfg.three_class_model.as_deref(), Some("org/align-sentinel"));
458 assert!((cfg.three_class_threshold - 0.65).abs() < 1e-6);
459 assert_eq!(cfg.three_class_model_sha256.as_deref(), Some("aabbcc"));
460 }
461
462 #[test]
463 fn pii_ner_allowlist_default_entries() {
464 let cfg = ClassifiersConfig::default();
465 assert!(cfg.pii_ner_allowlist.contains(&"Zeph".to_owned()));
466 assert!(cfg.pii_ner_allowlist.contains(&"Rust".to_owned()));
467 assert!(cfg.pii_ner_allowlist.contains(&"OpenAI".to_owned()));
468 assert!(cfg.pii_ner_allowlist.contains(&"Ollama".to_owned()));
469 assert!(cfg.pii_ner_allowlist.contains(&"Claude".to_owned()));
470 }
471
472 #[test]
473 fn pii_ner_allowlist_configurable() {
474 let toml = r#"pii_ner_allowlist = ["MyProject", "AcmeCorp"]"#;
475 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
476 assert_eq!(cfg.pii_ner_allowlist, vec!["MyProject", "AcmeCorp"]);
477 }
478
479 #[test]
480 fn pii_ner_allowlist_empty_disables() {
481 let toml = "pii_ner_allowlist = []";
482 let cfg: ClassifiersConfig = toml::from_str(toml).unwrap();
483 assert!(cfg.pii_ner_allowlist.is_empty());
484 }
485
486 #[test]
487 fn three_class_threshold_validation_rejects_zero() {
488 let result: Result<ClassifiersConfig, _> = toml::from_str("three_class_threshold = 0.0");
489 assert!(result.is_err());
490 }
491
492 #[test]
493 fn pii_ner_circuit_breaker_default() {
494 let cfg = ClassifiersConfig::default();
495 assert_eq!(cfg.pii_ner_circuit_breaker, 2);
496 }
497
498 #[test]
499 fn pii_ner_circuit_breaker_configurable() {
500 let cfg: ClassifiersConfig = toml::from_str("pii_ner_circuit_breaker = 5").unwrap();
501 assert_eq!(cfg.pii_ner_circuit_breaker, 5);
502 }
503
504 #[test]
505 fn pii_ner_circuit_breaker_zero_disables() {
506 let cfg: ClassifiersConfig = toml::from_str("pii_ner_circuit_breaker = 0").unwrap();
507 assert_eq!(cfg.pii_ner_circuit_breaker, 0);
508 }
509
510 #[test]
511 fn pii_ner_circuit_breaker_missing_uses_default() {
512 let cfg: ClassifiersConfig = toml::from_str("").unwrap();
513 assert_eq!(cfg.pii_ner_circuit_breaker, 2);
514 }
515}