ironflow_artifacts/
lib.rs1pub mod blob_store;
34pub mod error;
35pub mod gc;
36pub mod name;
37
38#[cfg(feature = "artifact-local")]
39pub mod local;
40
41#[cfg(feature = "storage-s3")]
42pub mod s3;
43
44use std::path::Path;
45
46use bytes::Bytes;
47use futures_util::stream::{once, unfold};
48use tokio::fs::File;
49use tokio::io::AsyncReadExt;
50
51use crate::blob_store::ByteStream;
52use crate::error::ArtifactError;
53
54const READ_CHUNK_BYTES: usize = 64 * 1024;
56
57pub fn stream_from_bytes(bytes: impl Into<Bytes>) -> ByteStream {
72 let bytes = bytes.into();
73 Box::pin(once(async move { Ok(bytes) }))
74}
75
76pub fn stream_from_file(file: File) -> ByteStream {
92 Box::pin(unfold(Some(file), |state| async move {
93 let mut file = state?;
94 let mut buf = vec![0u8; READ_CHUNK_BYTES];
95 match file.read(&mut buf).await {
96 Ok(0) => None,
97 Ok(read) => {
98 buf.truncate(read);
99 Some((Ok(Bytes::from(buf)), Some(file)))
100 }
101 Err(err) => Some((Err(ArtifactError::from(err)), None)),
102 }
103 }))
104}
105
106pub async fn stream_from_path(path: impl AsRef<Path>) -> Result<ByteStream, ArtifactError> {
125 let path = path.as_ref();
126 let file = File::open(path).await.map_err(|err| match err.kind() {
127 std::io::ErrorKind::NotFound => ArtifactError::NotFound(path.display().to_string()),
128 _ => ArtifactError::from(err),
129 })?;
130
131 Ok(stream_from_file(file))
132}
133
134pub mod prelude {
136 pub use crate::blob_store::{BlobDigest, BlobFuture, BlobStore, ByteStream};
137 pub use crate::error::ArtifactError;
138 pub use crate::name::{
139 MAX_ARTIFACT_NAME_LEN, guess_content_type, storage_key, validate_artifact_name,
140 };
141 pub use crate::{stream_from_bytes, stream_from_file, stream_from_path};
142
143 #[cfg(feature = "artifact-local")]
144 pub use crate::local::{DEFAULT_MAX_ARTIFACT_BYTES, LocalBlobStore};
145
146 #[cfg(feature = "storage-s3")]
147 pub use crate::s3::S3BlobStore;
148}
149
150#[cfg(test)]
151mod tests {
152 use futures_util::TryStreamExt;
153
154 use super::*;
155
156 #[tokio::test]
157 async fn stream_from_bytes_yields_the_whole_buffer() {
158 let chunks: Vec<Bytes> = stream_from_bytes(b"hello".to_vec())
159 .try_collect()
160 .await
161 .expect("collect");
162
163 assert_eq!(chunks.concat(), b"hello");
164 }
165
166 #[tokio::test]
167 async fn stream_from_bytes_supports_an_empty_buffer() {
168 let chunks: Vec<Bytes> = stream_from_bytes(Vec::new())
169 .try_collect()
170 .await
171 .expect("collect");
172
173 assert!(chunks.concat().is_empty());
174 }
175
176 #[tokio::test]
177 async fn stream_from_path_reads_a_file_larger_than_one_chunk() {
178 let dir = tempfile::TempDir::new().expect("temp dir");
179 let path = dir.path().join("big.bin");
180 let payload: Vec<u8> = (0..200_000u32).map(|i| (i % 256) as u8).collect();
181 std::fs::write(&path, &payload).expect("write");
182
183 let chunks: Vec<Bytes> = stream_from_path(&path)
184 .await
185 .expect("open")
186 .try_collect()
187 .await
188 .expect("collect");
189
190 assert_eq!(chunks.concat(), payload);
191 assert!(chunks.len() > 1, "large file should stream in chunks");
192 }
193
194 #[tokio::test]
195 async fn stream_from_path_reports_a_missing_file_as_not_found() {
196 let result = stream_from_path("/nonexistent/ironflow/artifact").await;
197
198 assert!(matches!(result, Err(ArtifactError::NotFound(_))));
199 }
200}