Skip to main content

rskit_storage/
source.rs

1//! File source — a reference to file content that can be read.
2
3use std::path::{Path, PathBuf};
4
5use bytes::Bytes;
6use futures::Stream;
7use rskit_errors::{AppError, AppResult, ErrorCode};
8use rskit_fs::async_io::file;
9use tokio::io::AsyncRead;
10
11use crate::TempFile;
12
13/// A reference to file content that can be read. Does NOT load content eagerly —
14/// all reads are lazy/streamed.
15///
16/// Serialization notes:
17/// - `Temp` serializes as `Path` (temp file's path).
18/// - `Bytes` serializes as a byte array.
19/// - Deserialized `Temp` becomes `Path` (temp ownership is not restored).
20#[derive(Debug)]
21pub enum FileSource {
22    /// Local filesystem path.
23    Path(PathBuf),
24    /// Remote URL (will be streamed on read, not eagerly downloaded).
25    Url(String),
26    /// In-memory bytes (for small files or test fixtures).
27    Bytes(Bytes),
28    /// Managed temporary file (auto-deleted on drop).
29    Temp(TempFile),
30}
31
32impl Clone for FileSource {
33    fn clone(&self) -> Self {
34        match self {
35            Self::Path(path) => Self::Path(path.clone()),
36            Self::Url(url) => Self::Url(url.clone()),
37            Self::Bytes(bytes) => Self::Bytes(bytes.clone()),
38            Self::Temp(temp) => match temp.try_clone() {
39                Ok(cloned) => Self::Temp(cloned),
40                Err(error) => {
41                    tracing::warn!(
42                        error = ?error,
43                        path = %temp.path().display(),
44                        "FileSource::Temp clone failed, falling back to Path"
45                    );
46                    Self::Path(temp.path().to_path_buf())
47                }
48            },
49        }
50    }
51}
52
53// -- Custom serde: Temp serializes as Path, Bytes as a byte vec --
54
55mod serde_impl {
56    use super::*;
57    use serde::{Deserialize, Deserializer, Serialize, Serializer};
58
59    #[derive(Serialize, Deserialize)]
60    #[serde(tag = "type", content = "value")]
61    enum FileSourceRepr {
62        Path(PathBuf),
63        Url(String),
64        Bytes(Vec<u8>),
65    }
66
67    impl Serialize for FileSource {
68        fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
69            match self {
70                FileSource::Path(p) => FileSourceRepr::Path(p.clone()).serialize(ser),
71                FileSource::Url(u) => FileSourceRepr::Url(u.clone()).serialize(ser),
72                FileSource::Bytes(b) => FileSourceRepr::Bytes(b.to_vec()).serialize(ser),
73                FileSource::Temp(t) => FileSourceRepr::Path(t.path().to_path_buf()).serialize(ser),
74            }
75        }
76    }
77
78    impl<'de> Deserialize<'de> for FileSource {
79        fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
80            let repr = FileSourceRepr::deserialize(de)?;
81            Ok(match repr {
82                FileSourceRepr::Path(p) => FileSource::Path(p),
83                FileSourceRepr::Url(u) => FileSource::Url(u),
84                FileSourceRepr::Bytes(b) => FileSource::Bytes(Bytes::from(b)),
85            })
86        }
87    }
88}
89
90impl FileSource {
91    /// Create a source from a local file path.
92    pub fn from_path(p: impl Into<PathBuf>) -> Self {
93        Self::Path(p.into())
94    }
95
96    /// Create a source from a remote URL.
97    pub fn from_url(url: impl Into<String>) -> Self {
98        Self::Url(url.into())
99    }
100
101    /// Create a source from in-memory bytes.
102    pub fn from_bytes(b: impl Into<Bytes>) -> Self {
103        Self::Bytes(b.into())
104    }
105
106    /// Open an async reader over this source.
107    pub async fn reader(&self) -> AppResult<Box<dyn AsyncRead + Send + Unpin>> {
108        match self {
109            Self::Path(p) => {
110                let file = file::open(p).await.map_err(|e| {
111                    AppError::new(
112                        ErrorCode::NotFound,
113                        format!("failed to open {}: {e}", p.display()),
114                    )
115                })?;
116                Ok(Box::new(file))
117            }
118            Self::Bytes(b) => Ok(Box::new(std::io::Cursor::new(b.clone()))),
119            Self::Temp(t) => {
120                let file = file::open(t.path()).await.map_err(|e| {
121                    AppError::new(
122                        ErrorCode::Internal,
123                        format!("failed to open temp file {}: {e}", t.path().display()),
124                    )
125                })?;
126                Ok(Box::new(file))
127            }
128            Self::Url(_url) => Err(AppError::new(
129                ErrorCode::InvalidInput,
130                "URL sources require an HTTP client; use to_local_path() first",
131            )),
132        }
133    }
134
135    /// Open a byte stream over this source.
136    pub async fn stream(&self) -> AppResult<impl Stream<Item = AppResult<Bytes>> + '_> {
137        use futures::stream;
138        use tokio::io::AsyncReadExt;
139
140        let mut reader = self.reader().await?;
141        let (tx, rx) = tokio::sync::mpsc::channel::<AppResult<Bytes>>(8);
142
143        let mut buf = vec![0u8; 64 * 1024];
144        tokio::spawn(async move {
145            loop {
146                match reader.read(&mut buf).await {
147                    Ok(0) => break,
148                    Ok(n) => {
149                        if tx
150                            .send(Ok(Bytes::copy_from_slice(&buf[..n])))
151                            .await
152                            .is_err()
153                        {
154                            break;
155                        }
156                    }
157                    Err(e) => {
158                        let _ = tx
159                            .send(Err(AppError::new(
160                                ErrorCode::Internal,
161                                format!("stream read error: {e}"),
162                            )))
163                            .await;
164                        break;
165                    }
166                }
167            }
168        });
169
170        Ok(stream::unfold(rx, |mut rx| async move {
171            rx.recv().await.map(|item| (item, rx))
172        }))
173    }
174
175    /// Read entire content into memory (use only for small files).
176    pub async fn read_all(&self) -> AppResult<Bytes> {
177        match self {
178            Self::Bytes(b) => Ok(b.clone()),
179            _ => {
180                use tokio::io::AsyncReadExt;
181                let mut reader = self.reader().await?;
182                let mut buf = Vec::new();
183                reader.read_to_end(&mut buf).await.map_err(|e| {
184                    AppError::new(ErrorCode::Internal, format!("failed to read file: {e}"))
185                })?;
186                Ok(Bytes::from(buf))
187            }
188        }
189    }
190
191    /// Size in bytes (may require a stat call).
192    pub async fn size(&self) -> AppResult<Option<u64>> {
193        match self {
194            Self::Path(p) => {
195                let meta = file::metadata(p).await.map_err(|e| {
196                    AppError::new(
197                        ErrorCode::NotFound,
198                        format!("failed to stat {}: {e}", p.display()),
199                    )
200                })?;
201                Ok(Some(meta.len))
202            }
203            Self::Bytes(b) => Ok(Some(b.len() as u64)),
204            Self::Temp(t) => {
205                let meta = file::metadata(t.path()).await.map_err(|e| {
206                    AppError::new(
207                        ErrorCode::Internal,
208                        format!("failed to stat temp file: {e}"),
209                    )
210                })?;
211                Ok(Some(meta.len))
212            }
213            Self::Url(_) => Ok(None),
214        }
215    }
216
217    /// Resolve to a local file path. Downloads to temp if source is URL/Bytes.
218    pub async fn to_local_path(&self) -> AppResult<ResolvedPath> {
219        match self {
220            Self::Path(p) => Ok(ResolvedPath {
221                path: p.clone(),
222                _temp: None,
223            }),
224            Self::Temp(t) => Ok(ResolvedPath {
225                path: t.path().to_path_buf(),
226                _temp: None,
227            }),
228            Self::Bytes(b) => {
229                let tmp = TempFile::new()?;
230                file::write(tmp.path(), b).await.map_err(|e| {
231                    AppError::new(
232                        ErrorCode::Internal,
233                        format!("failed to write bytes to temp file: {e}"),
234                    )
235                })?;
236                let path = tmp.path().to_path_buf();
237                Ok(ResolvedPath {
238                    path,
239                    _temp: Some(tmp),
240                })
241            }
242            Self::Url(_url) => Err(AppError::new(
243                ErrorCode::InvalidInput,
244                "URL download not yet implemented; use an HTTP client externally",
245            )),
246        }
247    }
248
249    /// File extension (from path or URL), if detectable.
250    pub fn extension(&self) -> Option<&str> {
251        match self {
252            Self::Path(p) => p.extension().and_then(|e| e.to_str()),
253            Self::Url(url) => {
254                let path = url.split('?').next().unwrap_or(url);
255                path.rsplit('.').next().filter(|ext| ext.len() <= 10)
256            }
257            Self::Temp(t) => t.path().extension().and_then(|e| e.to_str()),
258            Self::Bytes(_) => None,
259        }
260    }
261}
262
263/// A local path that may be backed by a temp file.
264/// The temp file (if any) is kept alive as long as this struct exists.
265pub struct ResolvedPath {
266    path: PathBuf,
267    _temp: Option<TempFile>,
268}
269
270impl ResolvedPath {
271    /// The local file path.
272    pub fn path(&self) -> &Path {
273        &self.path
274    }
275}
276
277impl AsRef<Path> for ResolvedPath {
278    fn as_ref(&self) -> &Path {
279        &self.path
280    }
281}
282
283impl std::fmt::Debug for ResolvedPath {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.debug_struct("ResolvedPath")
286            .field("path", &self.path)
287            .finish()
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use futures::StreamExt;
294    use serde_json::json;
295    use tokio::io::AsyncReadExt;
296
297    use super::*;
298
299    #[tokio::test]
300    async fn file_source_serializes_paths_urls_bytes_and_temp_as_stable_representations() {
301        let path = FileSource::from_path("data/file.txt");
302        assert_eq!(
303            serde_json::to_value(&path).unwrap(),
304            json!({"type":"Path","value":"data/file.txt"})
305        );
306        assert!(matches!(path.clone(), FileSource::Path(_)));
307
308        let url = FileSource::from_url("https://example.invalid/file.bin");
309        assert_eq!(
310            serde_json::to_value(&url).unwrap(),
311            json!({"type":"Url","value":"https://example.invalid/file.bin"})
312        );
313        assert!(matches!(url.clone(), FileSource::Url(_)));
314
315        let bytes = FileSource::from_bytes(Bytes::from_static(b"abc"));
316        assert_eq!(
317            serde_json::to_value(&bytes).unwrap(),
318            json!({"type":"Bytes","value":[97,98,99]})
319        );
320        assert!(matches!(bytes.clone(), FileSource::Bytes(_)));
321
322        let temp = TempFile::new().unwrap();
323        let value = serde_json::to_value(FileSource::Temp(temp)).unwrap();
324        assert_eq!(value["type"], "Path");
325
326        let decoded: FileSource =
327            serde_json::from_value(json!({"type":"Bytes","value":[1,2,3]})).unwrap();
328        assert_eq!(
329            decoded.read_all().await.unwrap(),
330            Bytes::from_static(&[1, 2, 3])
331        );
332    }
333
334    #[tokio::test]
335    async fn file_source_reads_sizes_streams_and_resolves_local_paths() {
336        let dir = tempfile::tempdir().unwrap();
337        let path = dir.path().join("input.txt");
338        tokio::fs::write(&path, b"path-data").await.unwrap();
339
340        let path_source = FileSource::from_path(&path);
341        assert_eq!(path_source.extension(), Some("txt"));
342        assert_eq!(path_source.size().await.unwrap(), Some(9));
343        assert_eq!(
344            path_source.read_all().await.unwrap(),
345            Bytes::from_static(b"path-data")
346        );
347        assert_eq!(
348            path_source.to_local_path().await.unwrap().path(),
349            path.as_path()
350        );
351
352        let mut reader = FileSource::from_bytes(Bytes::from_static(b"reader"))
353            .reader()
354            .await
355            .unwrap();
356        let mut data = Vec::new();
357        reader.read_to_end(&mut data).await.unwrap();
358        assert_eq!(data, b"reader");
359
360        let bytes_source = FileSource::from_bytes(Bytes::from_static(b"stream-data"));
361        let streamed = {
362            let stream = bytes_source.stream().await.unwrap();
363            futures::pin_mut!(stream);
364            let mut streamed = Vec::new();
365            while let Some(chunk) = stream.next().await {
366                streamed.extend_from_slice(&chunk.unwrap());
367            }
368            streamed
369        };
370        assert_eq!(streamed, b"stream-data");
371
372        let resolved = bytes_source.to_local_path().await.unwrap();
373        assert_eq!(
374            tokio::fs::read(resolved.path()).await.unwrap(),
375            b"stream-data"
376        );
377        assert!(format!("{resolved:?}").contains("ResolvedPath"));
378        assert_eq!(resolved.as_ref(), resolved.path());
379    }
380
381    #[tokio::test]
382    async fn file_source_handles_temp_url_and_missing_file_branches() {
383        let temp = TempFile::new().unwrap();
384        tokio::fs::write(temp.path(), b"temporary").await.unwrap();
385        let source = FileSource::Temp(temp);
386        assert_eq!(source.size().await.unwrap(), Some(9));
387        assert_eq!(
388            source.read_all().await.unwrap(),
389            Bytes::from_static(b"temporary")
390        );
391        assert_eq!(
392            source.to_local_path().await.unwrap().path().extension(),
393            None
394        );
395        assert!(matches!(source.clone(), FileSource::Temp(_)));
396
397        let url = FileSource::from_url("https://example.invalid/file.tar.gz?token=redacted");
398        assert_eq!(url.extension(), Some("gz"));
399        assert_eq!(url.size().await.unwrap(), None);
400        assert_eq!(
401            url.reader().await.err().unwrap().code(),
402            ErrorCode::InvalidInput
403        );
404        assert_eq!(
405            url.to_local_path().await.unwrap_err().code(),
406            ErrorCode::InvalidInput
407        );
408
409        let missing = FileSource::from_path("missing-file.bin");
410        assert_eq!(
411            missing.reader().await.err().unwrap().code(),
412            ErrorCode::NotFound
413        );
414        assert_eq!(
415            missing.size().await.unwrap_err().code(),
416            ErrorCode::NotFound
417        );
418    }
419
420    #[tokio::test]
421    async fn url_and_bytes_metadata_cover_fallback_mime_paths() {
422        let url = FileSource::from_url("https://example.invalid/archive.tar.gz?token=redacted");
423        let meta = crate::file_meta(&url).await.unwrap();
424        assert_eq!(meta.name.as_deref(), Some("archive.tar.gz"));
425        assert_eq!(meta.extension.as_deref(), Some("gz"));
426        assert_eq!(meta.size, None);
427        assert_eq!(
428            crate::detect_kind(&url).await.unwrap(),
429            crate::FileKind::Archive
430        );
431
432        let bytes = FileSource::from_bytes(Bytes::from_static(b"plain text"));
433        let meta = crate::file_meta(&bytes).await.unwrap();
434        assert_eq!(meta.name, None);
435        assert_eq!(meta.size, Some(10));
436        assert_eq!(meta.mime_type, "application/octet-stream");
437        assert_eq!(bytes.extension(), None);
438    }
439
440    #[tokio::test]
441    async fn path_metadata_uses_filesystem_timestamps_and_extension_mime() {
442        let dir = tempfile::tempdir().unwrap();
443        let path = dir.path().join("image.png");
444        tokio::fs::write(&path, b"not a real png").await.unwrap();
445        let source = FileSource::from_path(&path);
446
447        let meta = crate::file_meta(&source).await.unwrap();
448
449        assert_eq!(meta.name.as_deref(), Some("image.png"));
450        assert_eq!(meta.extension.as_deref(), Some("png"));
451        assert_eq!(meta.mime_type, "image/png");
452        assert_eq!(meta.size, Some(14));
453        assert!(meta.modified_at.is_some());
454    }
455
456    #[test]
457    fn extension_filters_overlong_url_suffixes() {
458        let url = FileSource::from_url("https://example.invalid/file.thisextensionistoolong");
459
460        assert_eq!(url.extension(), None);
461    }
462}