use std::{borrow::Cow, collections::VecDeque, io};
use tokio::io::{AsyncRead, AsyncReadExt};
const ELISION_MARKER_RESERVE: usize = 64;
const MIN_CAP_FOR_TWO_WINDOWS: usize = 256;
const TAIL_LINE_BOUNDARY_WINDOW: usize = 512;
#[derive(Clone, PartialEq, Eq)]
pub struct CapturedStream {
pub(super) bytes: Vec<u8>,
pub(super) truncated: bool,
}
impl CapturedStream {
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
pub fn into_bytes(self) -> Vec<u8> {
self.bytes
}
pub fn to_string_lossy(&self) -> Cow<'_, str> {
String::from_utf8_lossy(&self.bytes)
}
pub fn truncated(&self) -> bool {
self.truncated
}
pub fn len(&self) -> usize {
self.bytes.len()
}
pub fn is_empty(&self) -> bool {
self.bytes.is_empty()
}
}
impl std::fmt::Debug for CapturedStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CapturedStream")
.field("text", &self.to_string_lossy())
.field("truncated", &self.truncated)
.finish()
}
}
pub(super) async fn read_capped<R>(mut reader: R, max_bytes: usize) -> io::Result<CapturedStream>
where
R: AsyncRead + Unpin + Send + 'static,
{
let (head_budget, tail_budget) = if max_bytes < MIN_CAP_FOR_TWO_WINDOWS {
(max_bytes, 0)
} else {
let split = max_bytes - ELISION_MARKER_RESERVE;
let head = split / 2;
(head, split - head)
};
let mut head = Vec::new();
let mut tail: VecDeque<u8> = VecDeque::new();
let mut elided = 0_u64;
let mut buffer = [0u8; 8192];
loop {
let read = reader.read(&mut buffer).await?;
if read == 0 {
break;
}
let mut chunk = &buffer[..read];
let head_room = head_budget.saturating_sub(head.len());
if head_room > 0 {
let take = head_room.min(chunk.len());
head.extend_from_slice(&chunk[..take]);
chunk = &chunk[take..];
}
if chunk.is_empty() {
continue;
}
if tail_budget == 0 {
elided += chunk.len() as u64;
continue;
}
if chunk.len() >= tail_budget {
elided += tail.len() as u64 + (chunk.len() - tail_budget) as u64;
tail.clear();
tail.extend(&chunk[chunk.len() - tail_budget..]);
} else {
let overflow = (tail.len() + chunk.len()).saturating_sub(tail_budget);
elided += overflow as u64;
tail.drain(..overflow);
tail.extend(chunk);
}
}
if elided == 0 {
head.extend(tail);
return Ok(CapturedStream {
bytes: head,
truncated: false,
});
}
if tail.is_empty() {
return Ok(CapturedStream {
bytes: head,
truncated: true,
});
}
let tail = Vec::from(tail);
let boundary = tail[..TAIL_LINE_BOUNDARY_WINDOW.min(tail.len())]
.iter()
.position(|byte| *byte == b'\n')
.map(|index| index + 1)
.filter(|start| *start < tail.len())
.unwrap_or(0);
let elided = elided + boundary as u64;
let mut bytes = head;
bytes.extend_from_slice(format!("\n[... {elided} bytes elided ...]\n").as_bytes());
bytes.extend_from_slice(&tail[boundary..]);
Ok(CapturedStream {
bytes,
truncated: true,
})
}
#[cfg(test)]
mod tests {
use super::*;
async fn capture(input: &[u8], max_bytes: usize) -> CapturedStream {
read_capped(std::io::Cursor::new(input.to_vec()), max_bytes)
.await
.expect("cursor never fails to read")
}
#[tokio::test]
async fn a_stream_under_the_cap_is_byte_identical() {
let input = b"line one\nline two\nline three\n";
let captured = capture(input, 4096).await;
assert_eq!(captured.as_bytes(), input);
assert!(!captured.truncated());
}
#[tokio::test]
async fn a_capped_stream_keeps_the_end_a_failure_is_reported_at() {
let mut input = String::from("FIRST LINE\n");
for index in 0..4000 {
input.push_str(&format!("filler line {index}\n"));
}
input.push_str("LAST LINE: assertion failed\n");
let captured = capture(input.as_bytes(), 4096).await;
assert!(captured.truncated());
assert!(captured.len() <= 4096, "cap is still a hard bound");
let text = captured.to_string_lossy();
assert!(text.starts_with("FIRST LINE\n"), "{text}");
assert!(text.ends_with("LAST LINE: assertion failed\n"), "{text}");
assert!(text.contains("bytes elided"), "{text}");
}
#[tokio::test]
async fn a_cap_too_small_to_split_keeps_the_head() {
let captured = capture(b"aaaaaaaaaaaaaaaaaaaaaaaa", 8).await;
assert_eq!(captured.as_bytes(), b"aaaaaaaa");
assert!(captured.truncated());
}
}