Skip to main content

livekit_data_stream/incoming/
stream_reader.rs

1// Copyright 2026 LiveKit, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::{
16    info::{AnyStreamInfo, ByteStreamInfo, TextStreamInfo},
17    utils::{StreamError, StreamProgress, StreamResult},
18};
19use bytes::{Bytes, BytesMut};
20use futures_util::{Stream, StreamExt};
21use std::{
22    fmt::Debug,
23    pin::Pin,
24    task::{Context, Poll},
25};
26use tokio::sync::{
27    mpsc::{self, UnboundedReceiver, UnboundedSender},
28    watch,
29};
30use tokio_stream::wrappers::WatchStream;
31
32/// Reader for an incoming data stream.
33///
34/// The stream being read from is kept open as long as its reader exists;
35/// dropping the reader will close the stream.
36///
37pub trait StreamReader: Stream<Item = StreamResult<Self::Output>> {
38    /// Type of output this reader produces.
39    type Output;
40
41    /// Information about the underlying data stream.
42    type Info;
43
44    /// Returns a reference to the stream info.
45    fn info(&self) -> &Self::Info;
46
47    /// Returns a stream of [`StreamProgress`] events as the stream is incrementally received from
48    /// the sender participant.
49    fn progress(&self) -> impl Stream<Item = StreamProgress>;
50
51    /// Reads all incoming chunks from the byte stream, concatenating them
52    /// into a single value which is returned once the stream closes normally.
53    ///
54    /// Returns the data consisting of all concatenated chunks.
55    ///
56    fn read_all(self) -> impl std::future::Future<Output = StreamResult<Self::Output>> + Send;
57}
58
59/// Reader for an incoming byte data stream.
60pub struct ByteStreamReader {
61    info: ByteStreamInfo,
62    chunk_rx: UnboundedReceiver<StreamResult<Bytes>>,
63    progress_rx: watch::Receiver<StreamProgress>,
64}
65
66/// Reader for an incoming text data stream.
67pub struct TextStreamReader {
68    info: TextStreamInfo,
69    chunk_rx: UnboundedReceiver<StreamResult<Bytes>>,
70    progress_rx: watch::Receiver<StreamProgress>,
71}
72
73impl StreamReader for ByteStreamReader {
74    type Output = Bytes;
75    type Info = ByteStreamInfo;
76
77    fn info(&self) -> &ByteStreamInfo {
78        &self.info
79    }
80
81    fn progress(&self) -> impl Stream<Item = StreamProgress> {
82        WatchStream::new(self.progress_rx.clone())
83    }
84
85    async fn read_all(mut self) -> StreamResult<Bytes> {
86        let mut buffer = BytesMut::new();
87        while let Some(result) = self.next().await {
88            match result {
89                Ok(bytes) => buffer.extend_from_slice(&bytes),
90                Err(e) => return Err(e),
91            }
92        }
93        Ok(buffer.freeze())
94    }
95}
96
97impl ByteStreamReader {
98    /// Reads incoming chunks from the byte stream, writing them to a file as they are received.
99    ///
100    /// Parameters:
101    ///   - directory: The directory to write the file in. The system temporary directory is used if not specified.
102    ///   - name_override: The name to use for the written file, overriding stream name.
103    ///
104    /// Returns: The path of the written file on disk.
105    ///
106    /// Errors with [`StreamError::InvalidFileName`] if the file name (whether from the stream
107    /// info or `name_override`) is not a plain file name, i.e. contains path separators, `..`,
108    /// or is absolute.
109    ///
110    pub async fn write_to_file(
111        mut self,
112        directory: Option<impl AsRef<std::path::Path>>,
113        name_override: Option<&str>,
114    ) -> StreamResult<std::path::PathBuf> {
115        let directory =
116            directory.map(|d| d.as_ref().to_path_buf()).unwrap_or_else(|| std::env::temp_dir());
117        let name = name_override.unwrap_or_else(|| &self.info.name);
118        // The stream name comes from the remote sender: reject anything that isn't a plain
119        // file name so it can't escape the target directory.
120        let mut components = std::path::Path::new(name).components();
121        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
122            || components.next().is_some()
123        {
124            return Err(StreamError::InvalidFileName);
125        }
126        let file_path = directory.join(name);
127
128        let mut file = tokio::fs::File::create(&file_path).await.map_err(StreamError::Io)?;
129
130        while let Some(result) = self.next().await {
131            let bytes = result?;
132            tokio::io::AsyncWriteExt::write_all(&mut file, &bytes)
133                .await
134                .map_err(StreamError::Io)?;
135        }
136        tokio::io::AsyncWriteExt::flush(&mut file).await.map_err(StreamError::Io)?;
137
138        Ok(file_path)
139    }
140}
141
142impl Stream for ByteStreamReader {
143    type Item = StreamResult<Bytes>;
144
145    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
146        let this = self.get_mut();
147        match Pin::new(&mut this.chunk_rx).poll_recv(cx) {
148            Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(chunk))),
149            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
150            Poll::Ready(None) => Poll::Ready(None),
151            Poll::Pending => Poll::Pending,
152        }
153    }
154}
155
156#[cfg(any(test, feature = "test-utils"))]
157impl TextStreamReader {
158    /// Create a TextStreamReader for testing purposes.
159    ///
160    /// Exposed under the `test-utils` feature so downstream crates (e.g. `livekit`'s RPC tests)
161    /// can construct a reader directly.
162    pub fn new_for_test(
163        info: TextStreamInfo,
164        chunk_rx: UnboundedReceiver<StreamResult<Bytes>>,
165    ) -> Self {
166        // The progress channel is unused by these tests; seed it and drop the sender so the
167        // progress stream simply ends after the initial value.
168        let (_, progress_rx) = watch::channel(StreamProgress::default());
169        Self { info, chunk_rx, progress_rx }
170    }
171}
172
173impl StreamReader for TextStreamReader {
174    type Output = String;
175    type Info = TextStreamInfo;
176
177    fn info(&self) -> &TextStreamInfo {
178        &self.info
179    }
180
181    fn progress(&self) -> impl Stream<Item = StreamProgress> {
182        WatchStream::new(self.progress_rx.clone())
183    }
184
185    async fn read_all(mut self) -> StreamResult<String> {
186        let mut result = String::new();
187        while let Some(chunk) = self.next().await {
188            match chunk {
189                Ok(text) => result.push_str(&text),
190                Err(e) => return Err(e),
191            }
192        }
193        Ok(result)
194    }
195}
196
197impl Stream for TextStreamReader {
198    type Item = StreamResult<String>;
199
200    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
201        let this = self.get_mut();
202        match Pin::new(&mut this.chunk_rx).poll_recv(cx) {
203            Poll::Ready(Some(Ok(chunk))) => match String::from_utf8(chunk.into()) {
204                Ok(content) => Poll::Ready(Some(Ok(content))),
205                Err(e) => {
206                    this.chunk_rx.close();
207                    Poll::Ready(Some(Err(StreamError::from(e))))
208                }
209            },
210            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
211            Poll::Ready(None) => Poll::Ready(None),
212            Poll::Pending => Poll::Pending,
213        }
214    }
215}
216
217impl Debug for ByteStreamReader {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.debug_struct("ByteStreamReader")
220            .field("id", &self.info.id())
221            .field("topic", &self.info.topic)
222            .finish()
223    }
224}
225
226impl Debug for TextStreamReader {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        f.debug_struct("TextStreamReader")
229            .field("id", &self.info.id())
230            .field("topic", &self.info.topic)
231            .finish()
232    }
233}
234
235pub enum AnyStreamReader {
236    Byte(ByteStreamReader),
237    Text(TextStreamReader),
238}
239
240impl AnyStreamReader {
241    /// Creates a stream reader for the stream with the given info.
242    ///
243    /// Returns the reader along with the sender halves the manager uses to feed it: the chunk
244    /// channel for received content, and the progress channel for [`StreamProgress`] updates. The
245    /// progress channel is seeded with the initial progress (0 bytes, plus the total length when
246    /// the stream is finite).
247    pub(super) fn from(
248        info: AnyStreamInfo,
249    ) -> (Self, UnboundedSender<StreamResult<Bytes>>, watch::Sender<StreamProgress>) {
250        let (chunk_tx, chunk_rx) = mpsc::unbounded_channel();
251        let (progress_tx, progress_rx) = watch::channel(StreamProgress {
252            bytes_total: info.total_length(),
253            ..Default::default()
254        });
255        let reader = match info {
256            AnyStreamInfo::Byte(info) => {
257                Self::Byte(ByteStreamReader { info, chunk_rx, progress_rx })
258            }
259            AnyStreamInfo::Text(info) => {
260                Self::Text(TextStreamReader { info, chunk_rx, progress_rx })
261            }
262        };
263        return (reader, chunk_tx, progress_tx);
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::types::{ByteHeader, CompressionType, Header, StreamId};
271    use std::collections::HashMap;
272
273    /// Creates a byte stream reader whose stream info carries the given (potentially
274    /// remote-controlled) file name.
275    fn byte_reader(name: &str) -> (ByteStreamReader, UnboundedSender<StreamResult<Bytes>>) {
276        let header = Header {
277            stream_id: StreamId::from("stream-1"),
278            timestamp: 0,
279            topic: "topic".to_string(),
280            mime_type: "application/octet-stream".to_string(),
281            total_length: None,
282            attributes: HashMap::new(),
283            inline_content: None,
284            compression: CompressionType::None,
285            content_header: Some(ByteHeader { name: name.to_string() }.into()),
286        };
287        let AnyStreamInfo::Byte(info) = AnyStreamInfo::try_from(header).expect("valid header")
288        else {
289            panic!("expected a byte stream info");
290        };
291        let (chunk_tx, chunk_rx) = mpsc::unbounded_channel();
292        let (_, progress_rx) = watch::channel(StreamProgress::default());
293        (ByteStreamReader { info, chunk_rx, progress_rx }, chunk_tx)
294    }
295
296    #[tokio::test]
297    async fn write_to_file_rejects_traversal_in_stream_name() {
298        let (reader, _chunk_tx) = byte_reader("../evil.txt");
299        let result = reader.write_to_file(None::<&std::path::Path>, None).await;
300        assert!(matches!(result, Err(StreamError::InvalidFileName)));
301    }
302
303    #[tokio::test]
304    async fn write_to_file_rejects_traversal_in_name_override() {
305        let (reader, _chunk_tx) = byte_reader("safe.txt");
306        let result = reader.write_to_file(None::<&std::path::Path>, Some("../evil.txt")).await;
307        assert!(matches!(result, Err(StreamError::InvalidFileName)));
308    }
309
310    #[tokio::test]
311    async fn write_to_file_rejects_absolute_path_in_stream_name() {
312        let (reader, _chunk_tx) = byte_reader("/etc/evil.txt");
313        let result = reader.write_to_file(None::<&std::path::Path>, None).await;
314        assert!(matches!(result, Err(StreamError::InvalidFileName)));
315    }
316
317    #[tokio::test]
318    async fn write_to_file_rejects_nested_path_in_stream_name() {
319        let (reader, _chunk_tx) = byte_reader("nested/evil.txt");
320        let result = reader.write_to_file(None::<&std::path::Path>, None).await;
321        assert!(matches!(result, Err(StreamError::InvalidFileName)));
322    }
323
324    #[tokio::test]
325    async fn write_to_file_rejects_empty_stream_name() {
326        let (reader, _chunk_tx) = byte_reader("");
327        let result = reader.write_to_file(None::<&std::path::Path>, None).await;
328        assert!(matches!(result, Err(StreamError::InvalidFileName)));
329    }
330
331    #[tokio::test]
332    async fn write_to_file_accepts_plain_name() {
333        let directory =
334            std::env::temp_dir().join(format!("lk-stream-test-{}", uuid::Uuid::new_v4()));
335        tokio::fs::create_dir_all(&directory).await.expect("failed to create test directory");
336
337        let (reader, chunk_tx) = byte_reader("file.txt");
338        chunk_tx.send(Ok(Bytes::from_static(b"hello"))).expect("failed to send chunk");
339        drop(chunk_tx);
340
341        let path = reader
342            .write_to_file(Some(&directory), None)
343            .await
344            .expect("write_to_file should succeed for a plain file name");
345        assert_eq!(path, directory.join("file.txt"));
346        assert_eq!(tokio::fs::read(&path).await.expect("failed to read file"), b"hello");
347
348        tokio::fs::remove_dir_all(&directory).await.expect("failed to clean up test directory");
349    }
350}