use std::borrow::Cow;
use arrayvec::ArrayVec;
use flowscope::Timestamp;
use crate::anomaly::Severity;
use crate::anomaly::key::Key;
pub const ANOMALY_INLINE_CAPACITY: usize = 8;
pub trait AnomalySink: Send {
fn write(
&mut self,
kind: &'static str,
severity: Severity,
ts: Timestamp,
key: Option<&dyn Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
);
fn flush(&mut self) -> Result<(), std::io::Error> {
Ok(())
}
}
impl dyn AnomalySink + '_ {
pub fn begin(
&mut self,
kind: &'static str,
severity: Severity,
ts: Timestamp,
) -> AnomalyWriter<'_> {
AnomalyWriter::new(self, kind, severity, ts)
}
}
pub trait AnomalySinkExt: AnomalySink + Sized {
fn begin(
&mut self,
kind: &'static str,
severity: Severity,
ts: Timestamp,
) -> AnomalyWriter<'_> {
AnomalyWriter::new(self, kind, severity, ts)
}
}
impl<T: AnomalySink + Sized> AnomalySinkExt for T {}
pub fn publish_owned(sink: &mut dyn AnomalySink, owned: &flowscope::OwnedAnomaly) {
let mut observations: Vec<(&'static str, std::borrow::Cow<'_, str>)> = owned
.observations
.iter()
.map(|(k, v)| (*k, std::borrow::Cow::Borrowed(v.as_ref())))
.collect();
if let Some(tid) = owned.kind.attack_technique() {
observations.push(("attack_technique", std::borrow::Cow::Borrowed(tid)));
}
let metrics: Vec<(&'static str, f64)> = owned.metrics.iter().copied().collect();
let kind: &'static str = owned.kind.as_str();
sink.write(
kind,
owned.severity.into(),
owned.ts,
None,
&observations,
&metrics,
);
}
pub(crate) fn detector_kind_for(kind: &'static str) -> flowscope::DetectorKind {
let parsed = flowscope::DetectorKind::from_slug(kind);
if parsed.as_str() == kind {
parsed
} else {
flowscope::DetectorKind::Other(kind)
}
}
struct KeyRepr<'a> {
key: &'a dyn Key,
}
pub struct AnomalyWriter<'sink> {
sink: &'sink mut dyn AnomalySink,
kind: &'static str,
severity: Severity,
ts: Timestamp,
key_repr: Option<KeyRepr<'sink>>,
obs: ArrayVec<(&'static str, Cow<'sink, str>), ANOMALY_INLINE_CAPACITY>,
metrics: ArrayVec<(&'static str, f64), ANOMALY_INLINE_CAPACITY>,
}
impl<'sink> AnomalyWriter<'sink> {
pub(crate) fn new(
sink: &'sink mut dyn AnomalySink,
kind: &'static str,
severity: Severity,
ts: Timestamp,
) -> Self {
Self {
sink,
kind,
severity,
ts,
key_repr: None,
obs: ArrayVec::new(),
metrics: ArrayVec::new(),
}
}
pub fn with_key<K: Key>(mut self, key: &'sink K) -> Self {
let erased: &dyn Key = key;
self.key_repr = Some(KeyRepr { key: erased });
self
}
pub fn with(mut self, label: &'static str, value: impl Into<Cow<'sink, str>>) -> Self {
let _ = self.obs.try_push((label, value.into()));
self
}
pub fn with_dynamic(self, label: impl Into<String>, value: impl Into<Cow<'sink, str>>) -> Self {
let leaked: &'static str = Box::leak(label.into().into_boxed_str());
self.with(leaked, value)
}
pub fn with_metric(mut self, label: &'static str, value: f64) -> Self {
let _ = self.metrics.try_push((label, value));
self
}
pub fn emit(self) {
let key: Option<&dyn Key> = self.key_repr.as_ref().map(|k| k.key);
self.sink.write(
self.kind,
self.severity,
self.ts,
key,
&self.obs,
&self.metrics,
);
}
pub fn emit_owned(self) -> flowscope::OwnedAnomaly {
let mut owned = flowscope::OwnedAnomaly::new(
detector_kind_for(self.kind),
self.severity.into(),
self.ts,
);
if let Some(repr) = self.key_repr
&& let Some(fkey) = repr
.key
.as_any()
.downcast_ref::<flowscope::extract::FiveTupleKey>()
{
owned = owned.with_key(fkey);
}
for (label, value) in self.obs {
owned = owned.with_observation(label, value.into_owned());
}
for (label, value) in self.metrics {
owned = owned.with_metric(label, value);
}
owned
}
pub fn observation_count(&self) -> usize {
self.obs.len()
}
pub fn metric_count(&self) -> usize {
self.metrics.len()
}
}
pub struct NoopSink;
impl AnomalySink for NoopSink {
fn write(
&mut self,
_kind: &'static str,
_severity: Severity,
_ts: Timestamp,
_key: Option<&dyn Key>,
_observations: &[(&'static str, Cow<'_, str>)],
_metrics: &[(&'static str, f64)],
) {
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use super::*;
#[derive(Default)]
struct CaptureSink {
calls: Rc<RefCell<Vec<CapturedCall>>>,
}
#[derive(Debug, Clone, PartialEq)]
struct CapturedCall {
kind: &'static str,
severity: Severity,
obs_count: usize,
metric_count: usize,
has_key: bool,
}
unsafe impl Send for CaptureSink {}
impl AnomalySink for CaptureSink {
fn write(
&mut self,
kind: &'static str,
severity: Severity,
_ts: Timestamp,
key: Option<&dyn Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) {
self.calls.borrow_mut().push(CapturedCall {
kind,
severity,
obs_count: observations.len(),
metric_count: metrics.len(),
has_key: key.is_some(),
});
}
}
#[test]
fn noop_sink_is_object_safe_and_zero_cost() {
let mut sink = NoopSink;
let s: &mut dyn AnomalySink = &mut sink;
s.write("k", Severity::Info, Timestamp::new(0, 0), None, &[], &[]);
}
#[derive(Default)]
struct ObsSink {
obs: Vec<(&'static str, String)>,
}
unsafe impl Send for ObsSink {}
impl AnomalySink for ObsSink {
fn write(
&mut self,
_kind: &'static str,
_severity: Severity,
_ts: Timestamp,
_key: Option<&dyn Key>,
observations: &[(&'static str, Cow<'_, str>)],
_metrics: &[(&'static str, f64)],
) {
self.obs
.extend(observations.iter().map(|(k, v)| (*k, v.to_string())));
}
}
#[test]
fn publish_owned_appends_attack_technique_for_kinded_anomaly() {
let owned = flowscope::OwnedAnomaly::new(
flowscope::DetectorKind::BeaconRita,
flowscope::event::Severity::Warning,
Timestamp::new(0, 0),
);
let mut sink = ObsSink::default();
publish_owned(&mut sink, &owned);
assert!(
sink.obs
.iter()
.any(|(k, v)| *k == "attack_technique" && v == "T1071"),
"expected attack_technique=T1071, got {:?}",
sink.obs
);
}
#[test]
fn publish_owned_omits_attack_technique_for_untagged_kind() {
let owned = flowscope::OwnedAnomaly::new(
flowscope::DetectorKind::Other("custom"),
flowscope::event::Severity::Info,
Timestamp::new(0, 0),
);
let mut sink = ObsSink::default();
publish_owned(&mut sink, &owned);
assert!(
!sink.obs.iter().any(|(k, _)| *k == "attack_technique"),
"unexpected attack_technique for Other kind: {:?}",
sink.obs
);
}
#[test]
fn writer_records_kind_severity_and_counts() {
let mut sink = CaptureSink::default();
let calls = Rc::clone(&sink.calls);
sink.begin("TestKind", Severity::Warning, Timestamp::new(1, 0))
.with("note", "hi")
.with_metric("count", 7.0)
.emit();
let calls = calls.borrow();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].kind, "TestKind");
assert_eq!(calls[0].severity, Severity::Warning);
assert_eq!(calls[0].obs_count, 1);
assert_eq!(calls[0].metric_count, 1);
assert!(!calls[0].has_key);
}
#[test]
fn writer_with_key_marks_has_key() {
let mut sink = CaptureSink::default();
let calls = Rc::clone(&sink.calls);
let key = 42u32;
sink.begin("WithKey", Severity::Info, Timestamp::new(0, 0))
.with_key(&key)
.emit();
assert!(calls.borrow()[0].has_key);
}
#[test]
fn writer_drops_extra_observations_past_capacity() {
let mut sink = CaptureSink::default();
let calls = Rc::clone(&sink.calls);
const LABELS: [&str; ANOMALY_INLINE_CAPACITY + 4] =
["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"];
let mut w = sink.begin("Sat", Severity::Info, Timestamp::new(0, 0));
for label in &LABELS {
w = w.with(label, "v");
}
w.emit();
let calls = calls.borrow();
assert_eq!(
calls[0].obs_count, ANOMALY_INLINE_CAPACITY,
"writer must cap observations at ANOMALY_INLINE_CAPACITY"
);
}
#[test]
fn writer_drops_extra_metrics_past_capacity() {
let mut sink = CaptureSink::default();
let calls = Rc::clone(&sink.calls);
let mut w = sink.begin("Sat", Severity::Info, Timestamp::new(0, 0));
for _ in 0..(ANOMALY_INLINE_CAPACITY + 4) {
w = w.with_metric("m", 1.0);
}
w.emit();
assert_eq!(calls.borrow()[0].metric_count, ANOMALY_INLINE_CAPACITY);
}
#[test]
fn writer_emit_owned_materializes_without_firing_sink() {
let mut sink = NoopSink;
let owned = sink
.begin("Materialize", Severity::Warning, Timestamp::new(7, 0))
.with("note", "captured")
.with_metric("rate", 4.5)
.emit_owned();
assert_eq!(owned.kind.as_str(), "Materialize");
assert_eq!(owned.severity, flowscope::event::Severity::Warning);
assert_eq!(owned.ts, Timestamp::new(7, 0));
assert_eq!(owned.observations.len(), 1);
assert_eq!(owned.observations[0].0, "note");
assert_eq!(owned.observations[0].1.as_ref(), "captured");
assert_eq!(owned.metrics[0], ("rate", 4.5));
assert!(owned.src_ip.is_none() && owned.dest_ip.is_none());
}
#[test]
fn writer_emit_owned_with_five_tuple_key_populates_structured_fields() {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let key = flowscope::extract::FiveTupleKey::new(
flowscope::L4Proto::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 12345),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), 443),
);
let mut sink = NoopSink;
let owned = sink
.begin("PortScan", Severity::Error, Timestamp::new(0, 0))
.with_key(&key)
.emit_owned();
assert_eq!(owned.src_ip, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert_eq!(owned.src_port, Some(12345));
assert_eq!(owned.dest_ip, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2))));
assert_eq!(owned.dest_port, Some(443));
assert_eq!(owned.proto, Some("TCP"));
}
#[test]
fn writer_with_dynamic_leaks_label_to_static() {
let mut sink = CaptureSink::default();
let calls = Rc::clone(&sink.calls);
sink.begin("DynLabel", Severity::Info, Timestamp::new(0, 0))
.with_dynamic(format!("attempt_{}", 3), "captured")
.emit();
let recorded = &calls.borrow()[0];
assert_eq!(recorded.obs_count, 1);
}
#[test]
fn writer_static_str_value_stays_borrowed() {
let mut sink = CaptureSink::default();
let mut w = sink.begin("S", Severity::Info, Timestamp::new(0, 0));
w = w.with("k", "static-literal");
match &w.obs[0].1 {
Cow::Borrowed(_) => {}
Cow::Owned(_) => panic!("static literal should not allocate"),
}
w.emit();
}
#[test]
fn writer_counts_helpers() {
let mut sink = NoopSink;
let w = sink
.begin("X", Severity::Info, Timestamp::new(0, 0))
.with("a", "v")
.with_metric("m", 1.0);
assert_eq!(w.observation_count(), 1);
assert_eq!(w.metric_count(), 1);
w.emit();
}
}