1use std::collections::{BTreeMap, BTreeSet};
70
71use crate::config::{BlursToml, LocaleToml, SeverityToml};
72use crate::error::{Error, Result};
73use crate::moderation::types::ActionType;
74
75#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct LabelEmissionPolicy {
80 pub enabled: bool,
84 pub emit_reason_labels: bool,
87 pub warning_emits_label: bool,
89 pub reason_label_prefix: String,
92 pub action_label_overrides: BTreeMap<ActionType, LabelSpec>,
96 pub severity_overrides: BTreeMap<ActionType, SeverityToml>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct LabelSpec {
107 pub val: String,
109 pub severity: SeverityToml,
113 pub blurs: Option<BlursToml>,
117 pub locales: Vec<LocaleToml>,
121}
122
123impl LabelEmissionPolicy {
124 pub fn from_config(cfg: &crate::config::Config) -> Result<Self> {
131 let Some(toml) = cfg.label_emission.as_ref() else {
132 return Ok(Self::defaults());
133 };
134 Self::validated_from_toml(toml)
135 }
136
137 pub fn defaults() -> Self {
142 Self {
143 enabled: true,
144 emit_reason_labels: true,
145 warning_emits_label: false,
146 reason_label_prefix: "reason-".to_string(),
147 action_label_overrides: BTreeMap::new(),
148 severity_overrides: BTreeMap::new(),
149 }
150 }
151
152 fn validated_from_toml(toml: &crate::config::LabelEmissionPolicyToml) -> Result<Self> {
153 validate_reason_label_prefix(&toml.reason_label_prefix)?;
154
155 let mut overrides: BTreeMap<ActionType, LabelSpec> = BTreeMap::new();
161 let mut seen_vals: BTreeSet<String> = BTreeSet::new();
162 for (key, spec_toml) in &toml.action_label_overrides {
163 let action_type = ActionType::from_db_str(key).ok_or_else(|| {
164 Error::Signing(format!(
165 "config: [label_emission.action_label_overrides.{key}] is not a valid action_type \
166 (expected one of warning / note / temp_suspension / indef_suspension / takedown)"
167 ))
168 })?;
169 validate_label_val(&spec_toml.val).map_err(|e| {
170 Error::Signing(format!(
171 "config: [label_emission.action_label_overrides.{key}].val: {e}"
172 ))
173 })?;
174 if !seen_vals.insert(spec_toml.val.clone()) {
175 return Err(Error::Signing(format!(
176 "config: [label_emission.action_label_overrides] declares duplicate val \
177 {:?} across multiple action_type entries — each label val must be \
178 unique to a single action_type so revocation can route negation \
179 unambiguously",
180 spec_toml.val
181 )));
182 }
183 overrides.insert(
184 action_type,
185 LabelSpec {
186 val: spec_toml.val.clone(),
187 severity: spec_toml.severity,
188 blurs: spec_toml.blurs,
189 locales: spec_toml.locales.clone(),
190 },
191 );
192 }
193
194 let mut sev_overrides: BTreeMap<ActionType, SeverityToml> = BTreeMap::new();
196 for (key, severity) in &toml.severity_overrides {
197 let action_type = ActionType::from_db_str(key).ok_or_else(|| {
198 Error::Signing(format!(
199 "config: [label_emission.severity_overrides.{key}] is not a valid action_type"
200 ))
201 })?;
202 sev_overrides.insert(action_type, *severity);
203 }
204
205 Ok(Self {
206 enabled: toml.enabled,
207 emit_reason_labels: toml.emit_reason_labels,
208 warning_emits_label: toml.warning_emits_label,
209 reason_label_prefix: toml.reason_label_prefix.clone(),
210 action_label_overrides: overrides,
211 severity_overrides: sev_overrides,
212 })
213 }
214
215 pub fn resolve_action_label(&self, action_type: ActionType) -> Option<LabelSpec> {
221 if !self.enabled {
222 return None;
223 }
224 if matches!(action_type, ActionType::Note) {
225 return None;
230 }
231 if matches!(action_type, ActionType::Warning) && !self.warning_emits_label {
232 return None;
233 }
234
235 if let Some(spec) = self.action_label_overrides.get(&action_type) {
236 return Some(spec.clone());
240 }
241
242 let mut spec = default_spec_for(action_type)?;
245 if let Some(sev) = self.severity_overrides.get(&action_type) {
246 spec.severity = *sev;
247 }
248 Some(spec)
249 }
250
251 pub fn resolve_reason_label_value(&self, reason_code: &str) -> String {
256 format!("{}{}", self.reason_label_prefix, reason_code)
257 }
258}
259
260fn default_spec_for(action_type: ActionType) -> Option<LabelSpec> {
263 match action_type {
264 ActionType::Note => None,
265 ActionType::Takedown => Some(LabelSpec {
266 val: "!takedown".to_string(),
267 severity: SeverityToml::Alert,
268 blurs: None,
269 locales: Vec::new(),
270 }),
271 ActionType::IndefSuspension => Some(LabelSpec {
272 val: "!hide".to_string(),
273 severity: SeverityToml::Alert,
274 blurs: None,
275 locales: Vec::new(),
276 }),
277 ActionType::TempSuspension => Some(LabelSpec {
278 val: "!hide".to_string(),
279 severity: SeverityToml::Alert,
280 blurs: None,
281 locales: Vec::new(),
282 }),
283 ActionType::Warning => Some(LabelSpec {
284 val: "!warn".to_string(),
285 severity: SeverityToml::Inform,
286 blurs: None,
287 locales: Vec::new(),
288 }),
289 }
290}
291
292fn validate_label_val(val: &str) -> std::result::Result<(), String> {
300 if val.is_empty() {
301 return Err("label val must be non-empty".into());
302 }
303 if val.len() > 128 {
304 return Err(format!(
305 "label val {val:?} exceeds the §6.4 schema CHECK length limit (got {} bytes, max 128)",
306 val.len()
307 ));
308 }
309 let mut chars = val.chars();
310 let first = chars.next().expect("non-empty checked above");
311 if !first.is_ascii_lowercase() && !first.is_ascii_digit() && first != '!' {
312 return Err(format!(
313 "label val {val:?} must start with a lowercase ASCII letter, ASCII digit, or `!` \
314 (got {first:?})"
315 ));
316 }
317 for c in chars {
318 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
319 return Err(format!(
320 "label val {val:?} contains invalid char {c:?} \
321 (allowed after the first char: a-z, 0-9, hyphen)"
322 ));
323 }
324 }
325 Ok(())
326}
327
328fn validate_reason_label_prefix(prefix: &str) -> Result<()> {
334 if prefix.is_empty() {
335 tracing::warn!(
338 "config: [label_emission].reason_label_prefix is empty — \
339 reason labels will use bare reason_codes as their val"
340 );
341 return Ok(());
342 }
343 if prefix.len() > 32 {
344 return Err(Error::Signing(format!(
345 "config: [label_emission].reason_label_prefix {:?} exceeds 32 bytes (got {})",
346 prefix,
347 prefix.len()
348 )));
349 }
350 let mut chars = prefix.chars();
351 let first = chars.next().expect("non-empty checked above");
352 if !first.is_ascii_lowercase() {
353 return Err(Error::Signing(format!(
354 "config: [label_emission].reason_label_prefix {:?} must start with a lowercase \
355 ASCII letter (got {:?})",
356 prefix, first
357 )));
358 }
359 for c in chars {
360 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
361 return Err(Error::Signing(format!(
362 "config: [label_emission].reason_label_prefix {:?} contains invalid char {:?} \
363 (allowed: a-z, 0-9, hyphen)",
364 prefix, c
365 )));
366 }
367 }
368 Ok(())
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use crate::config::Config;
375
376 fn config_with_emission(value: serde_json::Value) -> Config {
377 let mut v = serde_json::json!({
378 "service_did": "did:web:labeler.example",
379 "service_endpoint": "https://labeler.example",
380 "db_path": "/var/lib/cairn/cairn.db",
381 "signing_key_path": "/etc/cairn/signing-key.hex",
382 });
383 if !value.is_null() {
384 v["label_emission"] = value;
385 }
386 serde_json::from_value(v).expect("config deserializes")
387 }
388
389 #[test]
392 fn defaults_match_documented_baseline() {
393 let p = LabelEmissionPolicy::defaults();
394 assert!(p.enabled);
395 assert!(p.emit_reason_labels);
396 assert!(!p.warning_emits_label);
397 assert_eq!(p.reason_label_prefix, "reason-");
398 assert!(p.action_label_overrides.is_empty());
399 assert!(p.severity_overrides.is_empty());
400 }
401
402 #[test]
403 fn absent_block_loads_defaults() {
404 let cfg = config_with_emission(serde_json::Value::Null);
405 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
406 assert_eq!(p, LabelEmissionPolicy::defaults());
407 }
408
409 #[test]
410 fn empty_block_loads_defaults_via_serde_fallbacks() {
411 let cfg = config_with_emission(serde_json::json!({}));
412 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
413 assert_eq!(p, LabelEmissionPolicy::defaults());
414 }
415
416 #[test]
419 fn partial_block_uses_serde_defaults_for_other_fields() {
420 let cfg = config_with_emission(serde_json::json!({
421 "warning_emits_label": true,
422 }));
423 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
424 assert!(p.warning_emits_label);
425 assert!(p.enabled);
426 assert!(p.emit_reason_labels);
427 assert_eq!(p.reason_label_prefix, "reason-");
428 }
429
430 #[test]
431 fn full_explicit_declaration_reflects_all_values() {
432 let cfg = config_with_emission(serde_json::json!({
433 "enabled": false,
434 "emit_reason_labels": false,
435 "warning_emits_label": true,
436 "reason_label_prefix": "rsn-",
437 }));
438 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
439 assert!(!p.enabled);
440 assert!(!p.emit_reason_labels);
441 assert!(p.warning_emits_label);
442 assert_eq!(p.reason_label_prefix, "rsn-");
443 }
444
445 #[test]
448 fn action_label_override_replaces_default_for_that_type() {
449 let cfg = config_with_emission(serde_json::json!({
450 "action_label_overrides": {
451 "takedown": {
452 "val": "!hideaway-takedown",
453 "severity": "alert",
454 }
455 }
456 }));
457 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
458 let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
459 assert_eq!(spec.val, "!hideaway-takedown");
460 }
461
462 #[test]
463 fn action_label_override_with_unknown_type_rejected() {
464 let cfg = config_with_emission(serde_json::json!({
465 "action_label_overrides": {
466 "ban": {
467 "val": "!banned",
468 "severity": "alert",
469 }
470 }
471 }));
472 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
473 let msg = format!("{err}");
474 assert!(msg.contains("not a valid action_type"));
475 assert!(msg.contains("ban"));
476 }
477
478 #[test]
479 fn duplicate_val_across_action_label_overrides_rejected() {
480 let cfg = config_with_emission(serde_json::json!({
481 "action_label_overrides": {
482 "takedown": { "val": "!enforced", "severity": "alert" },
483 "indef_suspension": { "val": "!enforced", "severity": "alert" },
484 }
485 }));
486 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
487 let msg = format!("{err}");
488 assert!(msg.contains("duplicate val"));
489 }
490
491 #[test]
492 fn action_label_override_with_blurs_and_locales_round_trips() {
493 let cfg = config_with_emission(serde_json::json!({
494 "action_label_overrides": {
495 "takedown": {
496 "val": "!takedown",
497 "severity": "alert",
498 "blurs": "media",
499 "locales": [
500 { "lang": "en", "name": "Removed", "description": "Account removed by moderation" }
501 ]
502 }
503 }
504 }));
505 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
506 let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
507 assert!(matches!(spec.blurs, Some(BlursToml::Media)));
508 assert_eq!(spec.locales.len(), 1);
509 assert_eq!(spec.locales[0].lang, "en");
510 }
511
512 #[test]
515 fn empty_val_rejected() {
516 let cfg = config_with_emission(serde_json::json!({
517 "action_label_overrides": {
518 "takedown": { "val": "", "severity": "alert" }
519 }
520 }));
521 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
522 assert!(format!("{err}").contains("non-empty"));
523 }
524
525 #[test]
526 fn uppercase_val_rejected() {
527 let cfg = config_with_emission(serde_json::json!({
530 "action_label_overrides": {
531 "takedown": { "val": "Bad-Val", "severity": "alert" }
532 }
533 }));
534 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
535 assert!(format!("{err}").contains("lowercase"));
536 }
537
538 #[test]
539 fn uppercase_in_middle_of_val_rejected() {
540 let cfg = config_with_emission(serde_json::json!({
543 "action_label_overrides": {
544 "takedown": { "val": "good-Val", "severity": "alert" }
545 }
546 }));
547 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
548 assert!(format!("{err}").contains("invalid char"));
549 }
550
551 #[test]
552 fn val_with_underscore_rejected() {
553 let cfg = config_with_emission(serde_json::json!({
554 "action_label_overrides": {
555 "takedown": { "val": "my_label", "severity": "alert" }
556 }
557 }));
558 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
559 assert!(format!("{err}").contains("invalid char"));
560 }
561
562 #[test]
563 fn val_starting_with_digit_accepted() {
564 let cfg = config_with_emission(serde_json::json!({
566 "action_label_overrides": {
567 "takedown": { "val": "2025-policy", "severity": "alert" }
568 }
569 }));
570 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
571 let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
572 assert_eq!(spec.val, "2025-policy");
573 }
574
575 #[test]
576 fn val_with_bang_prefix_accepted() {
577 let cfg = config_with_emission(serde_json::json!({
579 "action_label_overrides": {
580 "takedown": { "val": "!hideaway-takedown", "severity": "alert" }
581 }
582 }));
583 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
584 let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
585 assert_eq!(spec.val, "!hideaway-takedown");
586 }
587
588 #[test]
589 fn val_too_long_rejected() {
590 let long = "a".repeat(129);
591 let cfg = config_with_emission(serde_json::json!({
592 "action_label_overrides": {
593 "takedown": { "val": long, "severity": "alert" }
594 }
595 }));
596 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
597 assert!(format!("{err}").contains("128"));
598 }
599
600 #[test]
603 fn empty_prefix_is_valid_with_warning() {
604 let cfg = config_with_emission(serde_json::json!({
606 "reason_label_prefix": "",
607 }));
608 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
609 assert_eq!(p.reason_label_prefix, "");
610 assert_eq!(p.resolve_reason_label_value("hate-speech"), "hate-speech");
611 }
612
613 #[test]
614 fn uppercase_prefix_rejected() {
615 let cfg = config_with_emission(serde_json::json!({
616 "reason_label_prefix": "Reason-",
617 }));
618 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
619 assert!(format!("{err}").contains("lowercase"));
620 }
621
622 #[test]
623 fn prefix_with_underscore_rejected() {
624 let cfg = config_with_emission(serde_json::json!({
625 "reason_label_prefix": "rsn_",
626 }));
627 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
628 assert!(format!("{err}").contains("invalid char"));
629 }
630
631 #[test]
632 fn prefix_too_long_rejected() {
633 let long = "a".repeat(33);
634 let cfg = config_with_emission(serde_json::json!({
635 "reason_label_prefix": long,
636 }));
637 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
638 assert!(format!("{err}").contains("32 bytes"));
639 }
640
641 #[test]
644 fn severity_override_applies_when_no_full_override() {
645 let cfg = config_with_emission(serde_json::json!({
646 "warning_emits_label": true,
647 "severity_overrides": {
648 "warning": "alert",
649 }
650 }));
651 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
652 let spec = p.resolve_action_label(ActionType::Warning).unwrap();
653 assert!(matches!(spec.severity, SeverityToml::Alert));
654 assert_eq!(spec.val, "!warn"); }
656
657 #[test]
658 fn severity_override_ignored_when_full_override_present() {
659 let cfg = config_with_emission(serde_json::json!({
663 "action_label_overrides": {
664 "takedown": { "val": "!custom", "severity": "inform" }
665 },
666 "severity_overrides": {
667 "takedown": "alert",
668 }
669 }));
670 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
671 let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
672 assert!(matches!(spec.severity, SeverityToml::Inform));
673 }
674
675 #[test]
676 fn severity_override_with_unknown_type_rejected() {
677 let cfg = config_with_emission(serde_json::json!({
678 "severity_overrides": {
679 "ban": "alert",
680 }
681 }));
682 let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
683 assert!(format!("{err}").contains("not a valid action_type"));
684 }
685
686 #[test]
689 fn enabled_false_suppresses_all_emission() {
690 let cfg = config_with_emission(serde_json::json!({
691 "enabled": false,
692 }));
693 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
694 for at in [
695 ActionType::Takedown,
696 ActionType::IndefSuspension,
697 ActionType::TempSuspension,
698 ActionType::Warning,
699 ActionType::Note,
700 ] {
701 assert!(
702 p.resolve_action_label(at).is_none(),
703 "expected None for {at:?}"
704 );
705 }
706 }
707
708 #[test]
709 fn note_never_emits_regardless_of_config() {
710 let cfg = config_with_emission(serde_json::json!({
714 "action_label_overrides": {
715 "note": { "val": "!noted", "severity": "inform" }
716 }
717 }));
718 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
719 assert!(p.resolve_action_label(ActionType::Note).is_none());
720 }
721
722 #[test]
723 fn warning_emits_only_when_flag_true() {
724 let p = LabelEmissionPolicy::defaults();
726 assert!(p.resolve_action_label(ActionType::Warning).is_none());
727
728 let cfg = config_with_emission(serde_json::json!({
730 "warning_emits_label": true,
731 }));
732 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
733 let spec = p.resolve_action_label(ActionType::Warning).unwrap();
734 assert_eq!(spec.val, "!warn");
735 assert!(matches!(spec.severity, SeverityToml::Inform));
736 }
737
738 #[test]
739 fn defaults_resolve_to_atproto_globals() {
740 let p = LabelEmissionPolicy::defaults();
741 assert_eq!(
742 p.resolve_action_label(ActionType::Takedown).unwrap().val,
743 "!takedown"
744 );
745 assert_eq!(
746 p.resolve_action_label(ActionType::IndefSuspension)
747 .unwrap()
748 .val,
749 "!hide"
750 );
751 assert_eq!(
752 p.resolve_action_label(ActionType::TempSuspension)
753 .unwrap()
754 .val,
755 "!hide"
756 );
757 }
758
759 #[test]
762 fn reason_label_value_uses_default_prefix() {
763 let p = LabelEmissionPolicy::defaults();
764 assert_eq!(
765 p.resolve_reason_label_value("hate-speech"),
766 "reason-hate-speech"
767 );
768 assert_eq!(p.resolve_reason_label_value("spam"), "reason-spam");
769 }
770
771 #[test]
772 fn reason_label_value_uses_custom_prefix() {
773 let cfg = config_with_emission(serde_json::json!({
774 "reason_label_prefix": "rsn-",
775 }));
776 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
777 assert_eq!(
778 p.resolve_reason_label_value("hate-speech"),
779 "rsn-hate-speech"
780 );
781 }
782
783 #[test]
784 fn reason_label_value_returns_value_even_when_emit_reason_labels_false() {
785 let cfg = config_with_emission(serde_json::json!({
790 "emit_reason_labels": false,
791 }));
792 let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
793 assert!(!p.emit_reason_labels);
794 assert_eq!(p.resolve_reason_label_value("spam"), "reason-spam");
795 }
796}