Skip to main content

s3_wire/stream/
upload.rs

1use std::fmt;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::pin::Pin;
5use std::task::{Context, Poll};
6
7use base64::Engine as _;
8use bytes::Bytes;
9use futures_core::Stream;
10use hyper::body::{Body, Frame, SizeHint};
11use sha2::{Digest, Sha256};
12use tokio::fs::File;
13use tokio::io::{AsyncReadExt, AsyncWriteExt};
14use tokio::sync::Mutex;
15use tokio_util::io::ReaderStream;
16
17use crate::error::S3Error;
18
19type UploadItems = Pin<Box<dyn Stream<Item = Result<Bytes, io::Error>> + Send + 'static>>;
20
21/// An upload body with explicit length, integrity, and replay semantics.
22///
23/// Byte and file bodies are replayable. A caller-provided stream is one-shot
24/// and requires both its exact content length and SHA-256 digest so the client
25/// never buffers it or silently switches to unsigned payloads.
26pub struct ByteStream {
27    source: UploadSource,
28}
29
30enum UploadSource {
31    Bytes(Bytes),
32    File(PathBuf),
33    Stream {
34        stream: UploadItems,
35        length: u64,
36        sha256: [u8; 32],
37    },
38}
39
40impl ByteStream {
41    /// Creates a replayable in-memory body.
42    pub fn from_bytes(bytes: impl Into<Bytes>) -> Self {
43        Self {
44            source: UploadSource::Bytes(bytes.into()),
45        }
46    }
47
48    /// Creates a replayable file body.
49    ///
50    /// The explicitly supplied file is copied into a private disk-backed snapshot
51    /// while it is hashed. Every retry reads that immutable snapshot, so changes
52    /// to the original path cannot alter the signed upload bytes.
53    pub fn from_path(path: impl AsRef<Path>) -> Self {
54        Self {
55            source: UploadSource::File(path.as_ref().to_owned()),
56        }
57    }
58
59    /// Creates a one-shot stream with a caller-supplied exact digest.
60    ///
61    /// The stream is never retried. `sha256` is the digest of exactly `length`
62    /// bytes and is used for `SigV4` payload signing.
63    pub fn from_stream<S>(stream: S, length: u64, sha256: [u8; 32]) -> Self
64    where
65        S: Stream<Item = Result<Bytes, io::Error>> + Send + 'static,
66    {
67        Self {
68            source: UploadSource::Stream {
69                stream: Box::pin(stream),
70                length,
71                sha256,
72            },
73        }
74    }
75
76    pub(crate) async fn prepare(self) -> Result<PreparedBody, S3Error> {
77        match self.source {
78            UploadSource::Bytes(bytes) => {
79                let sha256 = Sha256::digest(&bytes).into();
80                let length = u64::try_from(bytes.len()).map_err(|_| {
81                    S3Error::configuration("in-memory body length does not fit in u64")
82                })?;
83                Ok(PreparedBody {
84                    source: PreparedSource::Bytes(bytes),
85                    length,
86                    sha256,
87                })
88            }
89            UploadSource::File(path) => prepare_file(path).await,
90            UploadSource::Stream {
91                stream,
92                length,
93                sha256,
94            } => Ok(PreparedBody {
95                source: PreparedSource::OneShot(Mutex::new(Some(stream))),
96                length,
97                sha256,
98            }),
99        }
100    }
101}
102
103impl From<Bytes> for ByteStream {
104    fn from(value: Bytes) -> Self {
105        Self::from_bytes(value)
106    }
107}
108
109impl From<Vec<u8>> for ByteStream {
110    fn from(value: Vec<u8>) -> Self {
111        Self::from_bytes(value)
112    }
113}
114
115impl From<&'static [u8]> for ByteStream {
116    fn from(value: &'static [u8]) -> Self {
117        Self::from_bytes(Bytes::from_static(value))
118    }
119}
120
121impl fmt::Debug for ByteStream {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        let (kind, replayable) = match self.source {
124            UploadSource::Bytes(_) => ("bytes", true),
125            UploadSource::File(_) => ("file", true),
126            UploadSource::Stream { .. } => ("stream", false),
127        };
128        formatter
129            .debug_struct("ByteStream")
130            .field("kind", &kind)
131            .field("replayable", &replayable)
132            .finish_non_exhaustive()
133    }
134}
135
136async fn prepare_file(path: PathBuf) -> Result<PreparedBody, S3Error> {
137    let mut file = File::open(&path).await.map_err(S3Error::transport)?;
138    let metadata = file.metadata().await.map_err(S3Error::transport)?;
139    if !metadata.is_file() {
140        return Err(S3Error::configuration(
141            "upload path must identify a regular file",
142        ));
143    }
144
145    // Copy to a private disk-backed snapshot while hashing. Every attempt opens
146    // this immutable snapshot, so the signed bytes cannot diverge if the caller's
147    // original path is replaced or modified during retries.
148    let snapshot = tempfile::NamedTempFile::new().map_err(S3Error::transport)?;
149    let (snapshot_file, snapshot_path) = snapshot.into_parts();
150    let mut snapshot_file = File::from_std(snapshot_file);
151    let mut hasher = Sha256::new();
152    let mut length = 0_u64;
153    let mut buffer = vec![0_u8; 64 * 1024];
154    loop {
155        let read = file.read(&mut buffer).await.map_err(S3Error::transport)?;
156        if read == 0 {
157            break;
158        }
159        length =
160            length
161                .checked_add(u64::try_from(read).map_err(|_| {
162                    S3Error::integrity("upload file chunk length does not fit in u64")
163                })?)
164                .ok_or_else(|| S3Error::integrity("upload file length overflow"))?;
165        hasher.update(&buffer[..read]);
166        snapshot_file
167            .write_all(&buffer[..read])
168            .await
169            .map_err(S3Error::transport)?;
170    }
171    snapshot_file.flush().await.map_err(S3Error::transport)?;
172    drop(snapshot_file);
173    let mut permissions = std::fs::metadata(&snapshot_path)
174        .map_err(S3Error::transport)?
175        .permissions();
176    permissions.set_readonly(true);
177    std::fs::set_permissions(&snapshot_path, permissions).map_err(S3Error::transport)?;
178
179    Ok(PreparedBody {
180        source: PreparedSource::FileSnapshot(snapshot_path),
181        length,
182        sha256: hasher.finalize().into(),
183    })
184}
185
186pub(crate) struct PreparedBody {
187    source: PreparedSource,
188    length: u64,
189    sha256: [u8; 32],
190}
191
192enum PreparedSource {
193    Bytes(Bytes),
194    FileSnapshot(tempfile::TempPath),
195    OneShot(Mutex<Option<UploadItems>>),
196}
197
198impl PreparedBody {
199    pub(crate) fn length(&self) -> u64 {
200        self.length
201    }
202
203    pub(crate) fn sha256_hex(&self) -> String {
204        encode_hex(&self.sha256)
205    }
206
207    pub(crate) fn sha256_base64(&self) -> String {
208        base64::engine::general_purpose::STANDARD.encode(self.sha256)
209    }
210
211    pub(crate) fn is_replayable(&self) -> bool {
212        !matches!(self.source, PreparedSource::OneShot(_))
213    }
214
215    pub(crate) async fn request_body(&self) -> Result<TransportBody, S3Error> {
216        match &self.source {
217            PreparedSource::Bytes(bytes) => Ok(TransportBody::new(
218                Box::pin(futures_util::stream::once(std::future::ready(Ok(
219                    bytes.clone()
220                )))),
221                self.length,
222                self.sha256,
223            )),
224            PreparedSource::FileSnapshot(path) => {
225                let file = File::open(path).await.map_err(S3Error::transport)?;
226                let stream = ReaderStream::with_capacity(file.take(self.length), 64 * 1024);
227                Ok(TransportBody::new(
228                    Box::pin(stream),
229                    self.length,
230                    self.sha256,
231                ))
232            }
233            PreparedSource::OneShot(stream) => {
234                let stream = stream.lock().await.take().ok_or_else(|| {
235                    S3Error::unsupported("a non-replayable upload body cannot be sent twice")
236                })?;
237                Ok(TransportBody::new(stream, self.length, self.sha256))
238            }
239        }
240    }
241}
242
243/// The crate-private HTTP body used by the transport.
244pub(crate) struct TransportBody {
245    stream: UploadItems,
246    remaining: u64,
247    expected_sha256: [u8; 32],
248    hasher: Sha256,
249    final_chunk: Option<Bytes>,
250    finished: bool,
251}
252
253impl TransportBody {
254    fn new(stream: UploadItems, length: u64, expected_sha256: [u8; 32]) -> Self {
255        Self {
256            stream,
257            remaining: length,
258            expected_sha256,
259            hasher: Sha256::new(),
260            final_chunk: None,
261            finished: false,
262        }
263    }
264
265    pub(crate) fn empty() -> Self {
266        let digest = Sha256::digest([]).into();
267        Self::new(Box::pin(futures_util::stream::empty()), 0, digest)
268    }
269}
270
271impl Body for TransportBody {
272    type Data = Bytes;
273    type Error = S3Error;
274
275    fn poll_frame(
276        mut self: Pin<&mut Self>,
277        context: &mut Context<'_>,
278    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
279        if self.finished {
280            return Poll::Ready(None);
281        }
282
283        loop {
284            if self.final_chunk.is_some() {
285                match self.stream.as_mut().poll_next(context) {
286                    Poll::Ready(Some(Ok(chunk))) if chunk.is_empty() => continue,
287                    Poll::Ready(Some(Ok(_))) => {
288                        self.finished = true;
289                        self.remaining = 0;
290                        self.final_chunk = None;
291                        return Poll::Ready(Some(Err(S3Error::integrity(
292                            "upload exceeded its declared content length",
293                        ))));
294                    }
295                    Poll::Ready(Some(Err(error))) => {
296                        self.finished = true;
297                        self.remaining = 0;
298                        self.final_chunk = None;
299                        return Poll::Ready(Some(Err(S3Error::transport(error))));
300                    }
301                    Poll::Ready(None) => {
302                        let digest: [u8; 32] = self.hasher.clone().finalize().into();
303                        if digest != self.expected_sha256 {
304                            self.finished = true;
305                            self.remaining = 0;
306                            self.final_chunk = None;
307                            return Poll::Ready(Some(Err(S3Error::integrity(
308                                "upload bytes did not match the signed payload digest",
309                            ))));
310                        }
311                        self.finished = true;
312                        self.remaining = 0;
313                        let chunk = self
314                            .final_chunk
315                            .take()
316                            .expect("final upload chunk was established");
317                        return Poll::Ready(Some(Ok(Frame::data(chunk))));
318                    }
319                    Poll::Pending => return Poll::Pending,
320                }
321            }
322
323            match self.stream.as_mut().poll_next(context) {
324                Poll::Ready(Some(Ok(chunk))) => {
325                    if chunk.is_empty() {
326                        continue;
327                    }
328                    let Ok(chunk_length) = u64::try_from(chunk.len()) else {
329                        self.finished = true;
330                        self.remaining = 0;
331                        return Poll::Ready(Some(Err(S3Error::integrity(
332                            "upload chunk length does not fit in u64",
333                        ))));
334                    };
335                    if chunk_length > self.remaining {
336                        self.finished = true;
337                        self.remaining = 0;
338                        return Poll::Ready(Some(Err(S3Error::integrity(
339                            "upload exceeded its declared content length",
340                        ))));
341                    }
342                    self.hasher.update(&chunk);
343                    if chunk_length == self.remaining {
344                        self.final_chunk = Some(chunk);
345                        continue;
346                    }
347                    self.remaining -= chunk_length;
348                    return Poll::Ready(Some(Ok(Frame::data(chunk))));
349                }
350                Poll::Ready(Some(Err(error))) => {
351                    self.finished = true;
352                    self.remaining = 0;
353                    return Poll::Ready(Some(Err(S3Error::transport(error))));
354                }
355                Poll::Ready(None) => {
356                    self.finished = true;
357                    if self.remaining != 0 {
358                        self.remaining = 0;
359                        return Poll::Ready(Some(Err(S3Error::integrity(
360                            "upload ended before its declared content length",
361                        ))));
362                    }
363                    let digest: [u8; 32] = self.hasher.clone().finalize().into();
364                    return if digest == self.expected_sha256 {
365                        Poll::Ready(None)
366                    } else {
367                        Poll::Ready(Some(Err(S3Error::integrity(
368                            "upload bytes did not match the signed payload digest",
369                        ))))
370                    };
371                }
372                Poll::Pending => return Poll::Pending,
373            }
374        }
375    }
376
377    fn is_end_stream(&self) -> bool {
378        self.finished
379    }
380
381    fn size_hint(&self) -> SizeHint {
382        SizeHint::with_exact(self.remaining)
383    }
384}
385
386fn encode_hex(bytes: &[u8]) -> String {
387    const HEX: &[u8; 16] = b"0123456789abcdef";
388    let mut output = String::with_capacity(bytes.len() * 2);
389    for byte in bytes {
390        output.push(char::from(HEX[usize::from(byte >> 4)]));
391        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
392    }
393    output
394}
395
396#[cfg(test)]
397mod tests {
398    use std::io;
399
400    use bytes::Bytes;
401    use futures_util::stream;
402    use http_body_util::BodyExt;
403    use sha2::{Digest, Sha256};
404    use tokio::io::AsyncWriteExt;
405
406    use super::ByteStream;
407
408    #[tokio::test]
409    async fn bytes_are_replayable_and_hashed() {
410        let prepared = ByteStream::from_bytes(Bytes::from_static(b"hello"))
411            .prepare()
412            .await
413            .expect("body prepares");
414        assert!(prepared.is_replayable());
415        assert_eq!(prepared.length(), 5);
416        assert_eq!(
417            prepared.sha256_hex(),
418            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
419        );
420        let first = prepared
421            .request_body()
422            .await
423            .expect("first body")
424            .collect()
425            .await
426            .expect("first body is valid")
427            .to_bytes();
428        let second = prepared
429            .request_body()
430            .await
431            .expect("second body")
432            .collect()
433            .await
434            .expect("second body is valid")
435            .to_bytes();
436        assert_eq!(first, Bytes::from_static(b"hello"));
437        assert_eq!(second, first);
438    }
439
440    #[tokio::test]
441    async fn one_shot_stream_rejects_second_attempt() {
442        let sha256 = Sha256::digest(b"x").into();
443        let source = stream::iter([Ok::<_, io::Error>(Bytes::from_static(b"x"))]);
444        let prepared = ByteStream::from_stream(source, 1, sha256)
445            .prepare()
446            .await
447            .expect("body prepares");
448        assert!(!prepared.is_replayable());
449        prepared
450            .request_body()
451            .await
452            .expect("first body")
453            .collect()
454            .await
455            .expect("first body is valid");
456        assert!(prepared.request_body().await.is_err());
457    }
458
459    #[tokio::test]
460    async fn file_retries_use_the_immutable_hashed_snapshot() {
461        let mut original = tempfile::NamedTempFile::new().unwrap();
462        std::io::Write::write_all(&mut original, b"signed bytes").unwrap();
463        std::io::Write::flush(&mut original).unwrap();
464        let prepared = ByteStream::from_path(original.path())
465            .prepare()
466            .await
467            .expect("file body prepares");
468
469        let mut changed = tokio::fs::File::create(original.path()).await.unwrap();
470        changed.write_all(b"different bytes").await.unwrap();
471        changed.flush().await.unwrap();
472
473        for _ in 0..2 {
474            let body = prepared
475                .request_body()
476                .await
477                .expect("snapshot opens")
478                .collect()
479                .await
480                .expect("snapshot body is valid")
481                .to_bytes();
482            assert_eq!(body, Bytes::from_static(b"signed bytes"));
483        }
484    }
485
486    #[tokio::test]
487    async fn one_shot_stream_enforces_declared_length_and_digest() {
488        let too_long = stream::iter([Ok::<_, io::Error>(Bytes::from_static(b"ab"))]);
489        let prepared = ByteStream::from_stream(too_long, 1, Sha256::digest(b"a").into())
490            .prepare()
491            .await
492            .unwrap();
493        assert!(
494            prepared
495                .request_body()
496                .await
497                .unwrap()
498                .collect()
499                .await
500                .is_err()
501        );
502
503        let wrong_digest = stream::iter([Ok::<_, io::Error>(Bytes::from_static(b"x"))]);
504        let prepared = ByteStream::from_stream(wrong_digest, 1, [0_u8; 32])
505            .prepare()
506            .await
507            .unwrap();
508        assert!(
509            prepared
510                .request_body()
511                .await
512                .unwrap()
513                .collect()
514                .await
515                .is_err()
516        );
517
518        let trailing_chunk = stream::iter([
519            Ok::<_, io::Error>(Bytes::from_static(b"a")),
520            Ok::<_, io::Error>(Bytes::from_static(b"b")),
521        ]);
522        let prepared = ByteStream::from_stream(trailing_chunk, 1, Sha256::digest(b"a").into())
523            .prepare()
524            .await
525            .unwrap();
526        let error = prepared
527            .request_body()
528            .await
529            .unwrap()
530            .collect()
531            .await
532            .unwrap_err();
533        assert_eq!(error.category(), crate::error::ErrorCategory::Integrity);
534    }
535}