use std::{fmt::Write as _, path::PathBuf};
use serde::{Deserialize, Serialize};
use tocat_api::{
Boundaries, BuildCtx, ChannelId, ChannelTarget, Ctx, Plugin, PluginError, PluginFactory,
Result, Stage,
};
pub const NAME: &str = "tee";
const DEFAULT_WIDTH: usize = 16;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DumpFormat {
Hex,
#[default]
#[serde(alias = "raw", alias = "binary")]
RawBinary,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct TeeConfig {
#[serde(default)]
pub file: Option<PathBuf>,
#[serde(default)]
pub format: DumpFormat,
#[serde(default = "default_true")]
pub append: bool,
#[serde(default)]
pub label: Option<String>,
#[serde(default = "default_width")]
pub width: usize,
}
impl Default for TeeConfig {
fn default() -> Self {
Self {
file: None,
format: DumpFormat::default(),
append: true,
label: None,
width: DEFAULT_WIDTH,
}
}
}
fn default_true() -> bool {
true
}
fn default_width() -> usize {
DEFAULT_WIDTH
}
impl TeeConfig {
pub fn target(&self) -> Result<ChannelTarget> {
let Some(path) = &self.file else {
return Ok(ChannelTarget::Stderr);
};
match path.to_str() {
Some("-" | "stderr" | "/dev/stderr" | "/dev/fd/2") => Ok(ChannelTarget::Stderr),
Some("stdout" | "/dev/stdout" | "/dev/fd/1") => Err(PluginError::config(
NAME,
"refusing to write to stdout, it may carry relay payload; use `-` for stderr",
)),
_ => Ok(ChannelTarget::File {
path: path.clone(),
append: self.append,
}),
}
}
}
pub struct Tee {
channel: ChannelId,
format: DumpFormat,
label: String,
width: usize,
offset: u64,
scratch: String,
}
impl Plugin for Tee {
fn name(&self) -> &str {
NAME
}
fn boundaries(&self) -> Boundaries {
Boundaries::Preserve
}
fn on_bytes(&mut self, ctx: &mut Ctx<'_>, input: &[u8]) -> Result<()> {
ctx.pass_through();
if input.is_empty() {
return Ok(());
}
match self.format {
DumpFormat::RawBinary => ctx.side_write(self.channel, input),
DumpFormat::Hex => {
self.scratch.clear();
let _ = writeln!(
self.scratch,
"[{}] {} bytes @ {:#x}",
self.label,
input.len(),
self.offset
);
write_hex_dump(&mut self.scratch, input, self.offset, self.width);
self.scratch.push('\n');
ctx.side_write(self.channel, self.scratch.as_bytes());
}
}
self.offset += input.len() as u64;
Ok(())
}
}
pub struct TeeFactory;
impl PluginFactory for TeeFactory {
fn name(&self) -> &str {
NAME
}
fn description(&self) -> &str {
"mirror the stream to a file or stderr, verbatim or as a hex dump"
}
fn build(&self, ctx: &mut BuildCtx<'_>) -> Result<Stage> {
let config: TeeConfig = ctx.config()?;
if config.width == 0 {
return Err(PluginError::config(NAME, "width must be at least 1"));
}
let channel = ctx.open_channel(config.target()?)?;
let label = config.label.clone().unwrap_or_else(|| {
let meta = ctx.meta().label();
let stage = ctx.stage().name;
format!("{meta} | {stage}")
});
Ok(Stage::filter(Tee {
channel,
format: config.format,
label,
width: config.width,
offset: 0,
scratch: String::new(),
}))
}
}
pub fn write_hex_dump(out: &mut String, buf: &[u8], start_offset: u64, width: usize) {
let width = width.max(1);
for (row, chunk) in buf.chunks(width).enumerate() {
if row > 0 {
out.push('\n');
}
let _ = write!(out, "{:08x} ", start_offset + (row * width) as u64);
for (i, byte) in chunk.iter().enumerate() {
if i > 0 {
out.push(' ');
}
let _ = write!(out, "{byte:02x}");
}
for _ in 0..(width - chunk.len()) * 3 {
out.push(' ');
}
out.push_str(" |");
for &byte in chunk {
out.push(if byte.is_ascii_graphic() || byte == b' ' {
byte as char
} else {
'.'
});
}
out.push('|');
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use tocat_api::{Direction, EffectSink, Emission, Emit, LogLevel, PipelineMeta, StageInfo};
use super::*;
fn hex_dump(buf: &[u8], start_offset: u64, width: usize) -> String {
let mut out = String::new();
write_hex_dump(&mut out, buf, start_offset, width);
out
}
#[derive(Default)]
struct Recorder(Vec<Vec<u8>>);
impl EffectSink for Recorder {
fn write(&mut self, _channel: ChannelId, bytes: &[u8]) {
self.0.push(bytes.to_vec());
}
fn log(&mut self, _level: LogLevel, _stage: &str, _message: &str) {}
}
struct CountingHost(u32);
impl tocat_api::HostBuilder for CountingHost {
fn open_channel(&mut self, _target: ChannelTarget) -> Result<ChannelId> {
let id = ChannelId(self.0);
self.0 += 1;
Ok(id)
}
}
fn meta() -> PipelineMeta {
PipelineMeta::new(Direction::SourceToSink, "tcp://a", "STDIO")
}
fn stage(name: &str) -> StageInfo<'_> {
StageInfo {
index: 0,
total: 1,
name,
upstream: "tcp://a",
downstream: "STDIO",
}
}
fn build_named(config: serde_json::Value, name: &str) -> Box<dyn Plugin> {
let map = config.as_object().expect("object").clone();
let meta = meta();
let mut host = CountingHost(0);
let mut ctx = BuildCtx::new(NAME, &map, &meta, stage(name), &mut host);
match TeeFactory.build(&mut ctx).expect("build") {
Stage::Filter(plugin) => plugin,
Stage::External(_) => unreachable!("tee is a filter"),
}
}
fn build(config: serde_json::Value) -> Box<dyn Plugin> {
build_named(config, NAME)
}
fn feed(plugin: &mut dyn Plugin, sink: &mut Recorder, input: &[u8]) -> Emit {
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).unwrap();
}
assert!(
emission.bytes().is_empty(),
"tee must never materialise the payload",
);
emission.emit()
}
#[test]
fn passes_through_and_mirrors_raw_bytes() {
let mut tee = build(json!({}));
let mut sink = Recorder::default();
assert_eq!(feed(tee.as_mut(), &mut sink, b"ping"), Emit::Passthrough);
assert_eq!(sink.0, vec![b"ping".to_vec()]);
}
#[test]
fn hex_header_tracks_offset() {
let mut tee = build(json!({ "format": "hex" }));
let mut sink = Recorder::default();
feed(tee.as_mut(), &mut sink, b"aaaa");
feed(tee.as_mut(), &mut sink, b"bb");
let first = String::from_utf8(sink.0[0].clone()).unwrap();
let second = String::from_utf8(sink.0[1].clone()).unwrap();
assert!(first.starts_with("[tcp://a -> STDIO | tee] 4 bytes @ 0x0\n"));
assert!(second.starts_with("[tcp://a -> STDIO | tee] 2 bytes @ 0x4\n"));
}
#[test]
fn header_carries_the_connection_and_the_tap() {
let mut tee = build_named(json!({ "format": "hex" }), "audit");
let mut sink = Recorder::default();
feed(tee.as_mut(), &mut sink, b"x");
let header = String::from_utf8(sink.0[0].clone()).unwrap();
assert!(header.starts_with("[tcp://a -> STDIO | audit] 1 bytes @ 0x0\n"));
}
#[test]
fn stdout_is_refused() {
let config = TeeConfig {
file: Some(PathBuf::from("/dev/stdout")),
..TeeConfig::default()
};
assert!(config.target().is_err());
}
#[test]
fn hex_rows_align() {
assert_eq!(hex_dump(b"hi", 0, 4), "00000000 68 69 |hi|");
assert_eq!(
hex_dump(b"hihi", 0, 2),
"00000000 68 69 |hi|\n00000002 68 69 |hi|"
);
}
}