use crate::{
info::{AnyStreamInfo, ByteStreamInfo, TextStreamInfo},
utils::{StreamError, StreamProgress, StreamResult},
};
use bytes::{Bytes, BytesMut};
use futures_util::{Stream, StreamExt};
use std::{
fmt::Debug,
pin::Pin,
task::{Context, Poll},
};
use tokio::sync::{
mpsc::{self, UnboundedReceiver, UnboundedSender},
watch,
};
use tokio_stream::wrappers::WatchStream;
pub trait StreamReader: Stream<Item = StreamResult<Self::Output>> {
type Output;
type Info;
fn info(&self) -> &Self::Info;
fn progress(&self) -> impl Stream<Item = StreamProgress>;
fn read_all(self) -> impl std::future::Future<Output = StreamResult<Self::Output>> + Send;
}
pub struct ByteStreamReader {
info: ByteStreamInfo,
chunk_rx: UnboundedReceiver<StreamResult<Bytes>>,
progress_rx: watch::Receiver<StreamProgress>,
}
pub struct TextStreamReader {
info: TextStreamInfo,
chunk_rx: UnboundedReceiver<StreamResult<Bytes>>,
progress_rx: watch::Receiver<StreamProgress>,
}
impl StreamReader for ByteStreamReader {
type Output = Bytes;
type Info = ByteStreamInfo;
fn info(&self) -> &ByteStreamInfo {
&self.info
}
fn progress(&self) -> impl Stream<Item = StreamProgress> {
WatchStream::new(self.progress_rx.clone())
}
async fn read_all(mut self) -> StreamResult<Bytes> {
let mut buffer = BytesMut::new();
while let Some(result) = self.next().await {
match result {
Ok(bytes) => buffer.extend_from_slice(&bytes),
Err(e) => return Err(e),
}
}
Ok(buffer.freeze())
}
}
impl ByteStreamReader {
pub async fn write_to_file(
mut self,
directory: Option<impl AsRef<std::path::Path>>,
name_override: Option<&str>,
) -> StreamResult<std::path::PathBuf> {
let directory =
directory.map(|d| d.as_ref().to_path_buf()).unwrap_or_else(|| std::env::temp_dir());
let name = name_override.unwrap_or_else(|| &self.info.name);
let mut components = std::path::Path::new(name).components();
if !matches!(components.next(), Some(std::path::Component::Normal(_)))
|| components.next().is_some()
{
return Err(StreamError::InvalidFileName);
}
let file_path = directory.join(name);
let mut file = tokio::fs::File::create(&file_path).await.map_err(StreamError::Io)?;
while let Some(result) = self.next().await {
let bytes = result?;
tokio::io::AsyncWriteExt::write_all(&mut file, &bytes)
.await
.map_err(StreamError::Io)?;
}
tokio::io::AsyncWriteExt::flush(&mut file).await.map_err(StreamError::Io)?;
Ok(file_path)
}
}
impl Stream for ByteStreamReader {
type Item = StreamResult<Bytes>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
match Pin::new(&mut this.chunk_rx).poll_recv(cx) {
Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(chunk))),
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
#[cfg(any(test, feature = "test-utils"))]
impl TextStreamReader {
pub fn new_for_test(
info: TextStreamInfo,
chunk_rx: UnboundedReceiver<StreamResult<Bytes>>,
) -> Self {
let (_, progress_rx) = watch::channel(StreamProgress::default());
Self { info, chunk_rx, progress_rx }
}
}
impl StreamReader for TextStreamReader {
type Output = String;
type Info = TextStreamInfo;
fn info(&self) -> &TextStreamInfo {
&self.info
}
fn progress(&self) -> impl Stream<Item = StreamProgress> {
WatchStream::new(self.progress_rx.clone())
}
async fn read_all(mut self) -> StreamResult<String> {
let mut result = String::new();
while let Some(chunk) = self.next().await {
match chunk {
Ok(text) => result.push_str(&text),
Err(e) => return Err(e),
}
}
Ok(result)
}
}
impl Stream for TextStreamReader {
type Item = StreamResult<String>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
match Pin::new(&mut this.chunk_rx).poll_recv(cx) {
Poll::Ready(Some(Ok(chunk))) => match String::from_utf8(chunk.into()) {
Ok(content) => Poll::Ready(Some(Ok(content))),
Err(e) => {
this.chunk_rx.close();
Poll::Ready(Some(Err(StreamError::from(e))))
}
},
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
impl Debug for ByteStreamReader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ByteStreamReader")
.field("id", &self.info.id())
.field("topic", &self.info.topic)
.finish()
}
}
impl Debug for TextStreamReader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextStreamReader")
.field("id", &self.info.id())
.field("topic", &self.info.topic)
.finish()
}
}
pub enum AnyStreamReader {
Byte(ByteStreamReader),
Text(TextStreamReader),
}
impl AnyStreamReader {
pub(super) fn from(
info: AnyStreamInfo,
) -> (Self, UnboundedSender<StreamResult<Bytes>>, watch::Sender<StreamProgress>) {
let (chunk_tx, chunk_rx) = mpsc::unbounded_channel();
let (progress_tx, progress_rx) = watch::channel(StreamProgress {
bytes_total: info.total_length(),
..Default::default()
});
let reader = match info {
AnyStreamInfo::Byte(info) => {
Self::Byte(ByteStreamReader { info, chunk_rx, progress_rx })
}
AnyStreamInfo::Text(info) => {
Self::Text(TextStreamReader { info, chunk_rx, progress_rx })
}
};
return (reader, chunk_tx, progress_tx);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ByteHeader, CompressionType, Header, StreamId};
use std::collections::HashMap;
fn byte_reader(name: &str) -> (ByteStreamReader, UnboundedSender<StreamResult<Bytes>>) {
let header = Header {
stream_id: StreamId::from("stream-1"),
timestamp: 0,
topic: "topic".to_string(),
mime_type: "application/octet-stream".to_string(),
total_length: None,
attributes: HashMap::new(),
inline_content: None,
compression: CompressionType::None,
content_header: Some(ByteHeader { name: name.to_string() }.into()),
};
let AnyStreamInfo::Byte(info) = AnyStreamInfo::try_from(header).expect("valid header")
else {
panic!("expected a byte stream info");
};
let (chunk_tx, chunk_rx) = mpsc::unbounded_channel();
let (_, progress_rx) = watch::channel(StreamProgress::default());
(ByteStreamReader { info, chunk_rx, progress_rx }, chunk_tx)
}
#[tokio::test]
async fn write_to_file_rejects_traversal_in_stream_name() {
let (reader, _chunk_tx) = byte_reader("../evil.txt");
let result = reader.write_to_file(None::<&std::path::Path>, None).await;
assert!(matches!(result, Err(StreamError::InvalidFileName)));
}
#[tokio::test]
async fn write_to_file_rejects_traversal_in_name_override() {
let (reader, _chunk_tx) = byte_reader("safe.txt");
let result = reader.write_to_file(None::<&std::path::Path>, Some("../evil.txt")).await;
assert!(matches!(result, Err(StreamError::InvalidFileName)));
}
#[tokio::test]
async fn write_to_file_rejects_absolute_path_in_stream_name() {
let (reader, _chunk_tx) = byte_reader("/etc/evil.txt");
let result = reader.write_to_file(None::<&std::path::Path>, None).await;
assert!(matches!(result, Err(StreamError::InvalidFileName)));
}
#[tokio::test]
async fn write_to_file_rejects_nested_path_in_stream_name() {
let (reader, _chunk_tx) = byte_reader("nested/evil.txt");
let result = reader.write_to_file(None::<&std::path::Path>, None).await;
assert!(matches!(result, Err(StreamError::InvalidFileName)));
}
#[tokio::test]
async fn write_to_file_rejects_empty_stream_name() {
let (reader, _chunk_tx) = byte_reader("");
let result = reader.write_to_file(None::<&std::path::Path>, None).await;
assert!(matches!(result, Err(StreamError::InvalidFileName)));
}
#[tokio::test]
async fn write_to_file_accepts_plain_name() {
let directory =
std::env::temp_dir().join(format!("lk-stream-test-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&directory).await.expect("failed to create test directory");
let (reader, chunk_tx) = byte_reader("file.txt");
chunk_tx.send(Ok(Bytes::from_static(b"hello"))).expect("failed to send chunk");
drop(chunk_tx);
let path = reader
.write_to_file(Some(&directory), None)
.await
.expect("write_to_file should succeed for a plain file name");
assert_eq!(path, directory.join("file.txt"));
assert_eq!(tokio::fs::read(&path).await.expect("failed to read file"), b"hello");
tokio::fs::remove_dir_all(&directory).await.expect("failed to clean up test directory");
}
}