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;
31use tokio_util::sync::CancellationToken;
32
33/// Reader for an incoming data stream.
34///
35/// The stream being read from is kept open as long as its reader exists;
36/// dropping the reader will close the stream.
37///
38pub trait StreamReader: Stream<Item = StreamResult<Self::Output>> {
39    /// Type of output this reader produces.
40    type Output;
41
42    /// Information about the underlying data stream.
43    type Info;
44
45    /// Returns a reference to the stream info.
46    fn info(&self) -> &Self::Info;
47
48    /// Returns a stream of [`StreamProgress`] events as the stream is incrementally received from
49    /// the sender participant.
50    fn progress(&self) -> impl Stream<Item = StreamProgress>;
51
52    /// Reads all incoming chunks from the byte stream, concatenating them
53    /// into a single value which is returned once the stream closes normally.
54    ///
55    /// Returns the data consisting of all concatenated chunks.
56    ///
57    fn read_all(self) -> impl std::future::Future<Output = StreamResult<Self::Output>> + Send;
58}
59
60/// Reader for an incoming byte data stream.
61pub struct ByteStreamReader {
62    info: ByteStreamInfo,
63    chunk_rx: UnboundedReceiver<StreamResult<Bytes>>,
64    progress_rx: watch::Receiver<StreamProgress>,
65    closer: StreamCloser,
66}
67
68/// Reader for an incoming text data stream.
69pub struct TextStreamReader {
70    info: TextStreamInfo,
71    chunk_rx: UnboundedReceiver<StreamResult<Bytes>>,
72    progress_rx: watch::Receiver<StreamProgress>,
73    closer: StreamCloser,
74}
75
76/// Ends an incoming stream from outside its reader, for example while another task reads it.
77///
78/// Get one from [`ByteStreamReader::closer`] or [`TextStreamReader::closer`].
79#[derive(Clone, Debug)]
80pub struct StreamCloser {
81    /// Cancelled to end this stream. The incoming manager watches it apart from its input queue.
82    pub(crate) token: CancellationToken,
83}
84
85impl StreamCloser {
86    /// A closer that does nothing, for readers made outside a manager.
87    #[cfg(any(test, feature = "test-utils"))]
88    fn detached() -> Self {
89        Self { token: CancellationToken::new() }
90    }
91
92    /// Ends the stream on the receiving side. The incoming manager drops the stream's state at
93    /// once and reports it closed. A read in progress fails with [`StreamError::AlreadyClosed`]
94    /// after the chunks that the manager already handed to the reader. Packets of this stream
95    /// that the manager has not processed yet are discarded, so a trailer still in its queue does
96    /// not merge its attributes.
97    ///
98    /// Does nothing once the stream has ended, and never affects a later stream that reuses its
99    /// id.
100    pub fn close(&self) {
101        self.token.cancel();
102    }
103}
104
105impl StreamReader for ByteStreamReader {
106    type Output = Bytes;
107    type Info = ByteStreamInfo;
108
109    fn info(&self) -> &ByteStreamInfo {
110        &self.info
111    }
112
113    fn progress(&self) -> impl Stream<Item = StreamProgress> {
114        self.progress_stream()
115    }
116
117    async fn read_all(mut self) -> StreamResult<Bytes> {
118        let mut buffer = BytesMut::new();
119        while let Some(result) = self.next().await {
120            match result {
121                Ok(bytes) => buffer.extend_from_slice(&bytes),
122                Err(e) => return Err(e),
123            }
124        }
125        Ok(buffer.freeze())
126    }
127}
128
129impl ByteStreamReader {
130    /// Like [`StreamReader::progress`], but the stream does not borrow the reader, so it can be
131    /// polled while the reader is read or after the reader is consumed.
132    pub fn progress_stream(&self) -> impl Stream<Item = StreamProgress> + Send + Unpin + 'static {
133        WatchStream::new(self.progress_rx.clone())
134    }
135
136    /// Returns a handle that ends this stream from outside the reader. See [`StreamCloser`].
137    pub fn closer(&self) -> StreamCloser {
138        self.closer.clone()
139    }
140
141    /// Reads incoming chunks from the byte stream, writing them to a file as they are received.
142    ///
143    /// Parameters:
144    ///   - directory: The directory to write the file in. The system temporary directory is used if not specified.
145    ///   - name_override: The name to use for the written file, overriding stream name.
146    ///
147    /// Returns: The path of the written file on disk.
148    ///
149    /// Errors with [`StreamError::InvalidFileName`] if the file name (whether from the stream
150    /// info or `name_override`) is not a plain file name, i.e. contains path separators, `..`,
151    /// or is absolute.
152    ///
153    pub async fn write_to_file(
154        mut self,
155        directory: Option<impl AsRef<std::path::Path>>,
156        name_override: Option<&str>,
157    ) -> StreamResult<std::path::PathBuf> {
158        let directory =
159            directory.map(|d| d.as_ref().to_path_buf()).unwrap_or_else(std::env::temp_dir);
160        let name = name_override.unwrap_or_else(|| &self.info.name);
161        // The stream name comes from the remote sender: reject anything that isn't a plain
162        // file name so it can't escape the target directory.
163        let mut components = std::path::Path::new(name).components();
164        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
165            || components.next().is_some()
166        {
167            return Err(StreamError::InvalidFileName);
168        }
169        let file_path = directory.join(name);
170
171        let mut file = tokio::fs::File::create(&file_path).await.map_err(StreamError::Io)?;
172
173        while let Some(result) = self.next().await {
174            let bytes = result?;
175            tokio::io::AsyncWriteExt::write_all(&mut file, &bytes)
176                .await
177                .map_err(StreamError::Io)?;
178        }
179        tokio::io::AsyncWriteExt::flush(&mut file).await.map_err(StreamError::Io)?;
180
181        Ok(file_path)
182    }
183}
184
185impl Stream for ByteStreamReader {
186    type Item = StreamResult<Bytes>;
187
188    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
189        let this = self.get_mut();
190        match Pin::new(&mut this.chunk_rx).poll_recv(cx) {
191            Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(chunk))),
192            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
193            Poll::Ready(None) => Poll::Ready(None),
194            Poll::Pending => Poll::Pending,
195        }
196    }
197}
198
199impl TextStreamReader {
200    /// Like [`StreamReader::progress`], but the stream does not borrow the reader, so it can be
201    /// polled while the reader is read or after the reader is consumed.
202    pub fn progress_stream(&self) -> impl Stream<Item = StreamProgress> + Send + Unpin + 'static {
203        WatchStream::new(self.progress_rx.clone())
204    }
205
206    /// Returns a handle that ends this stream from outside the reader. See [`StreamCloser`].
207    pub fn closer(&self) -> StreamCloser {
208        self.closer.clone()
209    }
210}
211
212#[cfg(any(test, feature = "test-utils"))]
213impl TextStreamReader {
214    /// Create a TextStreamReader for testing purposes.
215    ///
216    /// Exposed under the `test-utils` feature so downstream crates (e.g. `livekit`'s RPC tests)
217    /// can construct a reader directly.
218    pub fn new_for_test(
219        info: TextStreamInfo,
220        chunk_rx: UnboundedReceiver<StreamResult<Bytes>>,
221    ) -> Self {
222        // The progress channel is unused by these tests; seed it and drop the sender so the
223        // progress stream simply ends after the initial value.
224        let (_, progress_rx) = watch::channel(StreamProgress::default());
225        Self { info, chunk_rx, progress_rx, closer: StreamCloser::detached() }
226    }
227}
228
229impl StreamReader for TextStreamReader {
230    type Output = String;
231    type Info = TextStreamInfo;
232
233    fn info(&self) -> &TextStreamInfo {
234        &self.info
235    }
236
237    fn progress(&self) -> impl Stream<Item = StreamProgress> {
238        self.progress_stream()
239    }
240
241    async fn read_all(mut self) -> StreamResult<String> {
242        let mut result = String::new();
243        while let Some(chunk) = self.next().await {
244            match chunk {
245                Ok(text) => result.push_str(&text),
246                Err(e) => return Err(e),
247            }
248        }
249        Ok(result)
250    }
251}
252
253impl Stream for TextStreamReader {
254    type Item = StreamResult<String>;
255
256    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
257        let this = self.get_mut();
258        match Pin::new(&mut this.chunk_rx).poll_recv(cx) {
259            Poll::Ready(Some(Ok(chunk))) => match String::from_utf8(chunk.into()) {
260                Ok(content) => Poll::Ready(Some(Ok(content))),
261                Err(e) => {
262                    this.chunk_rx.close();
263                    Poll::Ready(Some(Err(StreamError::from(e))))
264                }
265            },
266            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
267            Poll::Ready(None) => Poll::Ready(None),
268            Poll::Pending => Poll::Pending,
269        }
270    }
271}
272
273impl Debug for ByteStreamReader {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        f.debug_struct("ByteStreamReader")
276            .field("id", &self.info.id())
277            .field("topic", &self.info.topic)
278            .finish()
279    }
280}
281
282impl Debug for TextStreamReader {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        f.debug_struct("TextStreamReader")
285            .field("id", &self.info.id())
286            .field("topic", &self.info.topic)
287            .finish()
288    }
289}
290
291pub enum AnyStreamReader {
292    Byte(ByteStreamReader),
293    Text(TextStreamReader),
294}
295
296impl AnyStreamReader {
297    /// Creates a stream reader for the stream with the given info.
298    ///
299    /// Returns the reader along with the sender halves the manager uses to feed it: the chunk
300    /// channel for received content, and the progress channel for [`StreamProgress`] updates. The
301    /// progress channel is seeded with the initial progress (0 bytes, plus the total length when
302    /// the stream is finite). The reader keeps `closer` to cancel the stream.
303    pub(super) fn from(
304        info: AnyStreamInfo,
305        closer: StreamCloser,
306    ) -> (Self, UnboundedSender<StreamResult<Bytes>>, watch::Sender<StreamProgress>) {
307        let (chunk_tx, chunk_rx) = mpsc::unbounded_channel();
308        let (progress_tx, progress_rx) = watch::channel(StreamProgress {
309            bytes_total: info.total_length(),
310            ..Default::default()
311        });
312        let reader = match info {
313            AnyStreamInfo::Byte(info) => {
314                Self::Byte(ByteStreamReader { info, chunk_rx, progress_rx, closer })
315            }
316            AnyStreamInfo::Text(info) => {
317                Self::Text(TextStreamReader { info, chunk_rx, progress_rx, closer })
318            }
319        };
320        (reader, chunk_tx, progress_tx)
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::types::{ByteHeader, CompressionType, Header, StreamId};
328    use std::collections::HashMap;
329
330    /// Creates a byte stream reader whose stream info carries the given (potentially
331    /// remote-controlled) file name.
332    fn byte_reader(name: &str) -> (ByteStreamReader, UnboundedSender<StreamResult<Bytes>>) {
333        let header = Header {
334            stream_id: StreamId::from("stream-1"),
335            timestamp: 0,
336            topic: "topic".to_string(),
337            mime_type: "application/octet-stream".to_string(),
338            total_length: None,
339            attributes: HashMap::new(),
340            inline_content: None,
341            compression: CompressionType::None,
342            content_header: Some(ByteHeader { name: name.to_string() }.into()),
343        };
344        let AnyStreamInfo::Byte(info) = AnyStreamInfo::try_from(header).expect("valid header")
345        else {
346            panic!("expected a byte stream info");
347        };
348        let (chunk_tx, chunk_rx) = mpsc::unbounded_channel();
349        let (_, progress_rx) = watch::channel(StreamProgress::default());
350        (
351            ByteStreamReader { info, chunk_rx, progress_rx, closer: StreamCloser::detached() },
352            chunk_tx,
353        )
354    }
355
356    #[tokio::test]
357    async fn write_to_file_rejects_traversal_in_stream_name() {
358        let (reader, _chunk_tx) = byte_reader("../evil.txt");
359        let result = reader.write_to_file(None::<&std::path::Path>, None).await;
360        assert!(matches!(result, Err(StreamError::InvalidFileName)));
361    }
362
363    #[tokio::test]
364    async fn write_to_file_rejects_traversal_in_name_override() {
365        let (reader, _chunk_tx) = byte_reader("safe.txt");
366        let result = reader.write_to_file(None::<&std::path::Path>, Some("../evil.txt")).await;
367        assert!(matches!(result, Err(StreamError::InvalidFileName)));
368    }
369
370    #[tokio::test]
371    async fn write_to_file_rejects_absolute_path_in_stream_name() {
372        let (reader, _chunk_tx) = byte_reader("/etc/evil.txt");
373        let result = reader.write_to_file(None::<&std::path::Path>, None).await;
374        assert!(matches!(result, Err(StreamError::InvalidFileName)));
375    }
376
377    #[tokio::test]
378    async fn write_to_file_rejects_nested_path_in_stream_name() {
379        let (reader, _chunk_tx) = byte_reader("nested/evil.txt");
380        let result = reader.write_to_file(None::<&std::path::Path>, None).await;
381        assert!(matches!(result, Err(StreamError::InvalidFileName)));
382    }
383
384    #[tokio::test]
385    async fn write_to_file_rejects_empty_stream_name() {
386        let (reader, _chunk_tx) = byte_reader("");
387        let result = reader.write_to_file(None::<&std::path::Path>, None).await;
388        assert!(matches!(result, Err(StreamError::InvalidFileName)));
389    }
390
391    #[tokio::test]
392    async fn write_to_file_accepts_plain_name() {
393        let directory =
394            std::env::temp_dir().join(format!("lk-stream-test-{}", uuid::Uuid::new_v4()));
395        tokio::fs::create_dir_all(&directory).await.expect("failed to create test directory");
396
397        let (reader, chunk_tx) = byte_reader("file.txt");
398        chunk_tx.send(Ok(Bytes::from_static(b"hello"))).expect("failed to send chunk");
399        drop(chunk_tx);
400
401        let path = reader
402            .write_to_file(Some(&directory), None)
403            .await
404            .expect("write_to_file should succeed for a plain file name");
405        assert_eq!(path, directory.join("file.txt"));
406        assert_eq!(tokio::fs::read(&path).await.expect("failed to read file"), b"hello");
407
408        tokio::fs::remove_dir_all(&directory).await.expect("failed to clean up test directory");
409    }
410}