1use std::time::SystemTime;
79
80use crate::config::{BlursToml, LocaleToml, SeverityToml};
81use crate::moderation::types::ActionType;
82
83use super::policy::LabelEmissionPolicy;
84
85#[derive(Debug, Clone)]
99pub struct ActionForEmission {
100 pub action_type: ActionType,
104 pub expires_at: Option<SystemTime>,
108 pub subject_did: String,
111 pub subject_uri: Option<String>,
114 pub reason_codes: Vec<String>,
118 pub cid: Option<String>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct LabelDraft {
142 pub val: String,
144 pub severity: SeverityToml,
149 pub blurs: Option<BlursToml>,
153 pub locales: Vec<LocaleToml>,
157 pub uri: String,
160 pub cid: Option<String>,
163 pub neg: bool,
167 pub cts: SystemTime,
171 pub exp: Option<SystemTime>,
175}
176
177pub fn resolve_action_labels(
181 action: &ActionForEmission,
182 policy: &LabelEmissionPolicy,
183 now: SystemTime,
184) -> Vec<LabelDraft> {
185 if !policy.enabled {
186 return Vec::new();
187 }
188
189 let Some(spec) = policy.resolve_action_label(action.action_type) else {
190 return Vec::new();
191 };
192
193 let uri = action
194 .subject_uri
195 .clone()
196 .unwrap_or_else(|| action.subject_did.clone());
197
198 let exp = exp_for(action);
199
200 vec![LabelDraft {
201 val: spec.val,
202 severity: spec.severity,
203 blurs: spec.blurs,
204 locales: spec.locales,
205 uri,
206 cid: None,
207 neg: false,
208 cts: now,
209 exp,
210 }]
211}
212
213pub fn resolve_reason_labels(
217 action: &ActionForEmission,
218 policy: &LabelEmissionPolicy,
219 now: SystemTime,
220) -> Vec<LabelDraft> {
221 if !policy.enabled || !policy.emit_reason_labels || action.reason_codes.is_empty() {
222 return Vec::new();
223 }
224 if matches!(action.action_type, ActionType::Note) {
225 return Vec::new();
226 }
227 if matches!(action.action_type, ActionType::Warning) && !policy.warning_emits_label {
228 return Vec::new();
231 }
232
233 let uri = action
234 .subject_uri
235 .clone()
236 .unwrap_or_else(|| action.subject_did.clone());
237 let exp = exp_for(action);
238
239 action
240 .reason_codes
241 .iter()
242 .map(|reason_code| LabelDraft {
243 val: policy.resolve_reason_label_value(reason_code),
244 severity: SeverityToml::Inform,
250 blurs: None,
251 locales: Vec::new(),
252 uri: uri.clone(),
253 cid: None,
254 neg: false,
255 cts: now,
256 exp,
257 })
258 .collect()
259}
260
261fn exp_for(action: &ActionForEmission) -> Option<SystemTime> {
268 match action.action_type {
269 ActionType::TempSuspension => action.expires_at,
270 _ => None,
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::config::{LabelEmissionPolicyToml, LabelSpecToml};
278 use std::collections::BTreeMap;
279 use std::time::{Duration, UNIX_EPOCH};
280
281 const SUBJECT_DID: &str = "did:plc:subject0000000000000000";
284 const SUBJECT_URI: &str = "at://did:plc:subject0000000000000000/app.bsky.feed.post/aaa";
285
286 fn t0() -> SystemTime {
287 UNIX_EPOCH + Duration::from_secs(2_000_000_000)
288 }
289
290 fn account_action(action_type: ActionType, reasons: &[&str]) -> ActionForEmission {
291 ActionForEmission {
292 action_type,
293 expires_at: None,
294 subject_did: SUBJECT_DID.to_string(),
295 subject_uri: None,
296 reason_codes: reasons.iter().map(|s| s.to_string()).collect(),
297 cid: None,
298 }
299 }
300
301 fn record_action(action_type: ActionType, reasons: &[&str]) -> ActionForEmission {
302 ActionForEmission {
303 action_type,
304 expires_at: None,
305 subject_did: SUBJECT_DID.to_string(),
306 subject_uri: Some(SUBJECT_URI.to_string()),
307 reason_codes: reasons.iter().map(|s| s.to_string()).collect(),
308 cid: None,
309 }
310 }
311
312 fn temp_suspension_action(reasons: &[&str], expires_at: SystemTime) -> ActionForEmission {
313 ActionForEmission {
314 action_type: ActionType::TempSuspension,
315 expires_at: Some(expires_at),
316 subject_did: SUBJECT_DID.to_string(),
317 subject_uri: None,
318 reason_codes: reasons.iter().map(|s| s.to_string()).collect(),
319 cid: None,
320 }
321 }
322
323 fn policy(patch: serde_json::Value) -> LabelEmissionPolicy {
327 let mut base = serde_json::json!({
328 "service_did": "did:web:labeler.example",
329 "service_endpoint": "https://labeler.example",
330 "db_path": "/var/lib/cairn/cairn.db",
331 "signing_key_path": "/etc/cairn/signing-key.hex",
332 });
333 if !patch.is_null() {
334 base["label_emission"] = patch;
335 }
336 let cfg: crate::config::Config = serde_json::from_value(base).expect("config deserializes");
337 LabelEmissionPolicy::from_config(&cfg).expect("policy resolves")
338 }
339
340 fn defaults_policy() -> LabelEmissionPolicy {
341 LabelEmissionPolicy::defaults()
342 }
343
344 fn _toml_types_exist() -> (LabelSpecToml, LabelEmissionPolicyToml, BTreeMap<String, ()>) {
349 unreachable!()
350 }
351
352 #[test]
357 fn action_labels_disabled_policy_returns_empty() {
358 let p = policy(serde_json::json!({ "enabled": false }));
359 assert!(
360 resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0()).is_empty()
361 );
362 }
363
364 #[test]
365 fn action_labels_note_returns_empty() {
366 let p = defaults_policy();
367 assert!(
368 resolve_action_labels(&account_action(ActionType::Note, &["spam"]), &p, t0())
369 .is_empty()
370 );
371 }
372
373 #[test]
374 fn action_labels_warning_suppressed_by_default() {
375 let p = defaults_policy();
376 assert!(
377 resolve_action_labels(&account_action(ActionType::Warning, &["spam"]), &p, t0())
378 .is_empty()
379 );
380 }
381
382 #[test]
383 fn action_labels_warning_emits_when_flag_true() {
384 let p = policy(serde_json::json!({ "warning_emits_label": true }));
385 let out = resolve_action_labels(&account_action(ActionType::Warning, &["spam"]), &p, t0());
386 assert_eq!(out.len(), 1);
387 assert_eq!(out[0].val, "!warn");
388 assert!(matches!(out[0].severity, SeverityToml::Inform));
389 assert!(!out[0].neg);
390 }
391
392 #[test]
393 fn action_labels_takedown_emits_default() {
394 let p = defaults_policy();
395 let out = resolve_action_labels(
396 &account_action(ActionType::Takedown, &["hate-speech"]),
397 &p,
398 t0(),
399 );
400 assert_eq!(out.len(), 1);
401 assert_eq!(out[0].val, "!takedown");
402 assert!(matches!(out[0].severity, SeverityToml::Alert));
403 assert_eq!(out[0].exp, None);
404 }
405
406 #[test]
407 fn action_labels_indef_suspension_emits_hide_no_exp() {
408 let p = defaults_policy();
409 let out =
410 resolve_action_labels(&account_action(ActionType::IndefSuspension, &[]), &p, t0());
411 assert_eq!(out.len(), 1);
412 assert_eq!(out[0].val, "!hide");
413 assert_eq!(out[0].exp, None);
414 }
415
416 #[test]
417 fn action_labels_temp_suspension_with_expiry_propagates_to_exp() {
418 let p = defaults_policy();
419 let exp = t0() + Duration::from_secs(7 * 86_400);
420 let out = resolve_action_labels(&temp_suspension_action(&[], exp), &p, t0());
421 assert_eq!(out.len(), 1);
422 assert_eq!(out[0].val, "!hide");
423 assert_eq!(out[0].exp, Some(exp));
424 }
425
426 #[test]
427 fn action_labels_temp_suspension_without_expiry_emits_with_none_exp() {
428 let p = defaults_policy();
433 let action = ActionForEmission {
434 action_type: ActionType::TempSuspension,
435 expires_at: None,
436 subject_did: SUBJECT_DID.to_string(),
437 subject_uri: None,
438 reason_codes: vec![],
439 cid: None,
440 };
441 let out = resolve_action_labels(&action, &p, t0());
442 assert_eq!(out.len(), 1);
443 assert_eq!(out[0].exp, None);
444 }
445
446 #[test]
447 fn action_labels_operator_override_replaces_val() {
448 let p = policy(serde_json::json!({
449 "action_label_overrides": {
450 "takedown": { "val": "!hideaway-takedown", "severity": "alert" }
451 }
452 }));
453 let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0());
454 assert_eq!(out[0].val, "!hideaway-takedown");
455 }
456
457 #[test]
458 fn action_labels_severity_override_applies_to_default_val() {
459 let p = policy(serde_json::json!({
460 "warning_emits_label": true,
461 "severity_overrides": { "warning": "alert" }
462 }));
463 let out = resolve_action_labels(&account_action(ActionType::Warning, &[]), &p, t0());
464 assert_eq!(out[0].val, "!warn"); assert!(matches!(out[0].severity, SeverityToml::Alert)); }
467
468 #[test]
469 fn action_labels_account_subject_uses_did_as_uri() {
470 let p = defaults_policy();
471 let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0());
472 assert_eq!(out[0].uri, SUBJECT_DID);
473 assert_eq!(out[0].cid, None);
474 }
475
476 #[test]
477 fn action_labels_record_subject_uses_uri() {
478 let p = defaults_policy();
479 let out = resolve_action_labels(&record_action(ActionType::Takedown, &[]), &p, t0());
480 assert_eq!(out[0].uri, SUBJECT_URI);
481 assert_eq!(out[0].cid, None);
482 }
483
484 #[test]
485 fn action_labels_cts_takes_now_arg() {
486 let p = defaults_policy();
487 let now = t0() + Duration::from_secs(42);
488 let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, now);
489 assert_eq!(out[0].cts, now);
490 }
491
492 #[test]
493 fn action_labels_blurs_and_locales_propagate_from_override() {
494 let p = policy(serde_json::json!({
495 "action_label_overrides": {
496 "takedown": {
497 "val": "!hideaway-takedown",
498 "severity": "alert",
499 "blurs": "media",
500 "locales": [
501 { "lang": "en", "name": "Removed", "description": "Account removed by moderation" }
502 ]
503 }
504 }
505 }));
506 let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0());
507 assert!(matches!(out[0].blurs, Some(BlursToml::Media)));
508 assert_eq!(out[0].locales.len(), 1);
509 assert_eq!(out[0].locales[0].lang, "en");
510 }
511
512 #[test]
517 fn reason_labels_disabled_policy_returns_empty() {
518 let p = policy(serde_json::json!({ "enabled": false }));
519 let action = account_action(ActionType::Takedown, &["spam", "hate-speech"]);
520 assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
521 }
522
523 #[test]
524 fn reason_labels_emit_reason_labels_false_returns_empty() {
525 let p = policy(serde_json::json!({ "emit_reason_labels": false }));
526 let action = account_action(ActionType::Takedown, &["spam"]);
527 assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
528 }
529
530 #[test]
531 fn reason_labels_empty_reason_codes_returns_empty() {
532 let p = defaults_policy();
533 let action = account_action(ActionType::Takedown, &[]);
534 assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
535 }
536
537 #[test]
538 fn reason_labels_note_returns_empty_even_with_reasons() {
539 let p = defaults_policy();
540 let action = account_action(ActionType::Note, &["spam", "hate-speech"]);
541 assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
542 }
543
544 #[test]
545 fn reason_labels_warning_suppressed_by_default() {
546 let p = defaults_policy();
549 let action = account_action(ActionType::Warning, &["spam"]);
550 assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
551 }
552
553 #[test]
554 fn reason_labels_warning_emits_when_warning_emits_label_true() {
555 let p = policy(serde_json::json!({ "warning_emits_label": true }));
556 let action = account_action(ActionType::Warning, &["spam"]);
557 let out = resolve_reason_labels(&action, &p, t0());
558 assert_eq!(out.len(), 1);
559 assert_eq!(out[0].val, "reason-spam");
560 }
561
562 #[test]
563 fn reason_labels_takedown_with_three_reasons_emits_three() {
564 let p = defaults_policy();
565 let action = account_action(ActionType::Takedown, &["spam", "hate-speech", "harassment"]);
566 let out = resolve_reason_labels(&action, &p, t0());
567 assert_eq!(out.len(), 3);
568 let vals: Vec<&str> = out.iter().map(|d| d.val.as_str()).collect();
569 assert_eq!(
570 vals,
571 vec!["reason-spam", "reason-hate-speech", "reason-harassment"]
572 );
573 for d in &out {
574 assert!(matches!(d.severity, SeverityToml::Inform));
575 assert!(!d.neg);
576 assert_eq!(d.cts, t0());
577 assert_eq!(d.uri, SUBJECT_DID);
578 }
579 }
580
581 #[test]
582 fn reason_labels_temp_suspension_inherits_action_exp() {
583 let p = defaults_policy();
584 let exp = t0() + Duration::from_secs(7 * 86_400);
585 let action = temp_suspension_action(&["spam", "nsfw"], exp);
586 let out = resolve_reason_labels(&action, &p, t0());
587 assert_eq!(out.len(), 2);
588 for d in &out {
589 assert_eq!(d.exp, Some(exp));
590 }
591 }
592
593 #[test]
594 fn reason_labels_use_custom_prefix() {
595 let p = policy(serde_json::json!({ "reason_label_prefix": "rsn-" }));
596 let action = account_action(ActionType::Takedown, &["spam"]);
597 let out = resolve_reason_labels(&action, &p, t0());
598 assert_eq!(out[0].val, "rsn-spam");
599 }
600
601 #[test]
602 fn reason_labels_use_empty_prefix_yields_bare_reason_codes() {
603 let p = policy(serde_json::json!({ "reason_label_prefix": "" }));
604 let action = account_action(ActionType::Takedown, &["spam"]);
605 let out = resolve_reason_labels(&action, &p, t0());
606 assert_eq!(out[0].val, "spam");
607 }
608
609 #[test]
610 fn reason_labels_record_subject_uses_uri() {
611 let p = defaults_policy();
612 let action = record_action(ActionType::Takedown, &["spam"]);
613 let out = resolve_reason_labels(&action, &p, t0());
614 assert_eq!(out[0].uri, SUBJECT_URI);
615 }
616
617 #[test]
618 fn reason_labels_takedown_has_no_exp() {
619 let p = defaults_policy();
620 let action = account_action(ActionType::Takedown, &["spam"]);
621 let out = resolve_reason_labels(&action, &p, t0());
622 assert_eq!(out[0].exp, None);
623 }
624
625 #[test]
630 fn outputs_deterministic_for_same_inputs() {
631 let p = defaults_policy();
632 let action = account_action(ActionType::Takedown, &["spam", "hate-speech"]);
633 let now = t0();
634 let a = resolve_action_labels(&action, &p, now);
635 let b = resolve_action_labels(&action, &p, now);
636 assert_eq!(a, b);
637 let r1 = resolve_reason_labels(&action, &p, now);
638 let r2 = resolve_reason_labels(&action, &p, now);
639 assert_eq!(r1, r2);
640 }
641}