use serde::{Deserialize, Serialize};
use tocat_api::{Boundaries, BuildCtx, ByteSize, Ctx, Plugin, PluginFactory, Result, Stage};
pub const NAME: &str = "limit";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum AtLimit {
Drop,
#[default]
Exact,
Overshoot,
}
impl AtLimit {
#[must_use]
pub fn splits(self) -> bool {
matches!(self, Self::Exact)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct LimitConfig {
#[serde(alias = "max", alias = "size")]
pub bytes: ByteSize,
#[serde(default)]
pub at_limit: AtLimit,
}
pub struct Limit {
cap: u64,
seen: u64,
at_limit: AtLimit,
stopped: bool,
}
impl Limit {
fn stop(&mut self, ctx: &mut Ctx<'_>) {
self.stopped = true;
ctx.halt(&format!(
"limit of {} reached at {}",
ByteSize(self.cap as usize),
ByteSize(self.seen as usize),
));
}
}
impl Plugin for Limit {
fn name(&self) -> &str {
NAME
}
fn on_bytes(&mut self, ctx: &mut Ctx<'_>, input: &[u8]) -> Result<()> {
if self.stopped {
ctx.drop_chunk();
return Ok(());
}
let remaining = self.cap.saturating_sub(self.seen);
let len = input.len() as u64;
if len < remaining {
self.seen += len;
ctx.pass_through();
return Ok(());
}
if len == remaining {
self.seen += len;
ctx.pass_through();
} else {
match self.at_limit {
AtLimit::Drop => ctx.drop_chunk(),
AtLimit::Exact => {
ctx.forward(&input[..remaining as usize]);
self.seen += remaining;
}
AtLimit::Overshoot => {
self.seen += len;
ctx.pass_through();
}
}
}
self.stop(ctx);
Ok(())
}
fn boundaries(&self) -> Boundaries {
if self.at_limit.splits() {
Boundaries::Fuse
} else {
Boundaries::Preserve
}
}
}
pub struct LimitFactory;
impl PluginFactory for LimitFactory {
fn name(&self) -> &str {
NAME
}
fn description(&self) -> &str {
"end the stream after a fixed number of bytes"
}
fn build(&self, ctx: &mut BuildCtx<'_>) -> Result<Stage> {
let config: LimitConfig = ctx.config()?;
Ok(Stage::filter(Limit {
cap: config.bytes.bytes() as u64,
seen: 0,
at_limit: config.at_limit,
stopped: false,
}))
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use tocat_api::{
ChannelId, ChannelTarget, Direction, EffectSink, Emission, Emit, HostBuilder, LogLevel,
PipelineMeta, Result as PluginResult, StageInfo,
};
use super::*;
#[derive(Default)]
struct Recorder {
halt: Option<String>,
}
impl EffectSink for Recorder {
fn write(&mut self, _channel: ChannelId, _bytes: &[u8]) {}
fn log(&mut self, _level: LogLevel, _stage: &str, _message: &str) {}
fn halt(&mut self, _stage: &str, reason: &str) {
self.halt.get_or_insert_with(|| reason.to_string());
}
}
struct NullHost;
impl HostBuilder for NullHost {
fn open_channel(&mut self, _target: ChannelTarget) -> PluginResult<ChannelId> {
Ok(ChannelId(0))
}
}
fn meta() -> PipelineMeta {
PipelineMeta::new(Direction::SourceToSink, "src", "sink")
}
fn stage() -> StageInfo<'static> {
StageInfo {
index: 0,
total: 1,
name: NAME,
upstream: "src",
downstream: "sink",
}
}
fn build(config: serde_json::Value) -> Box<dyn Plugin> {
let map = config.as_object().expect("object").clone();
let meta = meta();
let mut host = NullHost;
let mut ctx = BuildCtx::new(NAME, &map, &meta, stage(), &mut host);
match LimitFactory.build(&mut ctx).expect("build") {
Stage::Filter(plugin) => plugin,
Stage::External(_) => unreachable!("limit is a filter"),
}
}
fn build_config(config: serde_json::Value) -> LimitConfig {
let map = config.as_object().expect("object").clone();
let meta = meta();
let mut host = NullHost;
let ctx = BuildCtx::new(NAME, &map, &meta, stage(), &mut host);
ctx.config().expect("config")
}
fn feed(plugin: &mut dyn Plugin, sink: &mut Recorder, input: &[u8]) -> Vec<u8> {
let meta = meta();
let mut emission = Emission::new();
{
let mut ctx = Ctx::new(&meta, NAME, input, &mut emission, sink);
plugin.on_bytes(&mut ctx, input).expect("on_bytes");
}
match emission.emit() {
Emit::Passthrough => input.to_vec(),
Emit::Buffered => emission.bytes().to_vec(),
Emit::Pending => Vec::new(),
}
}
#[test]
fn bytes_under_the_limit_pass_untouched() {
let mut plugin = build(json!({"bytes": 16}));
let mut sink = Recorder::default();
assert_eq!(feed(&mut *plugin, &mut sink, b"hello"), b"hello");
assert!(sink.halt.is_none(), "nothing to stop for yet");
}
#[test]
fn the_crossing_chunk_is_split_and_the_stream_ends() {
let mut plugin = build(json!({"bytes": 8}));
let mut sink = Recorder::default();
assert_eq!(feed(&mut *plugin, &mut sink, b"12345"), b"12345");
assert_eq!(feed(&mut *plugin, &mut sink, b"67890"), b"678");
assert!(sink.halt.is_some(), "the limit must stop the read");
}
#[test]
fn landing_exactly_on_the_limit_still_stops() {
let mut plugin = build(json!({"bytes": 5}));
let mut sink = Recorder::default();
assert_eq!(feed(&mut *plugin, &mut sink, b"12345"), b"12345");
assert!(sink.halt.is_some());
}
#[test]
fn drop_discards_the_crossing_chunk_whole() {
let mut plugin = build(json!({"bytes": 8, "at-limit": "drop"}));
let mut sink = Recorder::default();
assert_eq!(feed(&mut *plugin, &mut sink, b"12345"), b"12345");
assert!(
feed(&mut *plugin, &mut sink, b"67890").is_empty(),
"at most `bytes` means the whole chunk goes",
);
assert!(sink.halt.is_some());
}
#[test]
fn overshoot_forwards_the_crossing_chunk_whole() {
let mut plugin = build(json!({"bytes": 8, "at-limit": "overshoot"}));
let mut sink = Recorder::default();
assert_eq!(feed(&mut *plugin, &mut sink, b"12345"), b"12345");
assert_eq!(
feed(&mut *plugin, &mut sink, b"67890"),
b"67890",
"at least `bytes` means the message is not cut",
);
assert!(sink.halt.is_some());
}
#[test]
fn every_mode_takes_a_chunk_that_lands_on_the_limit_whole() {
for mode in ["drop", "exact", "overshoot"] {
let mut plugin = build(json!({"bytes": 5, "at-limit": mode}));
let mut sink = Recorder::default();
assert_eq!(
feed(&mut *plugin, &mut sink, b"12345"),
b"12345",
"{mode} cut a chunk that needed no decision",
);
assert!(sink.halt.is_some(), "{mode} did not stop");
}
}
#[test]
fn a_chunk_arriving_after_the_limit_is_dropped_quietly() {
let mut plugin = build(json!({"bytes": 4}));
let mut sink = Recorder::default();
feed(&mut *plugin, &mut sink, b"12345");
let first = sink.halt.clone();
assert!(feed(&mut *plugin, &mut sink, b"more").is_empty());
assert_eq!(sink.halt, first, "the limit is announced once");
}
#[test]
fn splitting_is_what_makes_it_unsafe_on_datagrams() {
let boundaries = |config| build(config).boundaries();
assert_eq!(
boundaries(json!({"bytes": 8})),
Boundaries::Fuse,
"exact splits",
);
assert_eq!(
boundaries(json!({"bytes": 8, "at-limit": "drop"})),
Boundaries::Preserve,
);
assert_eq!(
boundaries(json!({"bytes": 8, "at-limit": "overshoot"})),
Boundaries::Preserve,
);
}
#[test]
fn the_mode_is_spelled_however_you_like() {
assert_eq!(
build_config(json!({"bytes": 8, "at_limit": "Overshoot"})).at_limit,
AtLimit::Overshoot,
);
}
#[test]
fn an_unknown_mode_is_rejected() {
let map = json!({"bytes": 8, "at-limit": "sideways"})
.as_object()
.expect("object")
.clone();
let meta = meta();
let mut host = NullHost;
let mut ctx = BuildCtx::new(NAME, &map, &meta, stage(), &mut host);
assert!(LimitFactory.build(&mut ctx).is_err());
}
#[test]
fn the_size_grammar_is_the_usual_one() {
let mut plugin = build(json!({"bytes": "1k"}));
let mut sink = Recorder::default();
let chunk = vec![0u8; 1000];
assert_eq!(feed(&mut *plugin, &mut sink, &chunk).len(), 1000);
assert!(sink.halt.is_none(), "1k is 1024, so 1000 is under it");
}
}