| 353 |
353 |
|
}
|
| 354 |
354 |
|
}
|
| 355 |
355 |
|
|
|
356 |
+ |
/// Aggregate a `ByteStream` into memory, aborting once more than `max_bytes`
|
|
357 |
+ |
/// have been read. Backs [`StorageBackend::download_object_buf_capped`]; factored
|
|
358 |
+ |
/// out as a free function so the cap logic is unit-testable without a full
|
|
359 |
+ |
/// backend. `label` is only used in the error message (the object key).
|
|
360 |
+ |
pub(crate) async fn read_bytestream_capped(
|
|
361 |
+ |
mut stream: s3_storage::ByteStream,
|
|
362 |
+ |
label: &str,
|
|
363 |
+ |
max_bytes: u64,
|
|
364 |
+ |
) -> Result<bytes::Bytes> {
|
|
365 |
+ |
let mut buf = bytes::BytesMut::new();
|
|
366 |
+ |
let mut read: u64 = 0;
|
|
367 |
+ |
loop {
|
|
368 |
+ |
match stream.try_next().await {
|
|
369 |
+ |
Ok(Some(chunk)) => {
|
|
370 |
+ |
read += chunk.len() as u64;
|
|
371 |
+ |
if read > max_bytes {
|
|
372 |
+ |
return Err(AppError::Storage(format!(
|
|
373 |
+ |
"object {label} exceeds scan in-memory cap ({read} > {max_bytes} bytes); \
|
|
374 |
+ |
recorded size under-reported the real object"
|
|
375 |
+ |
)));
|
|
376 |
+ |
}
|
|
377 |
+ |
buf.extend_from_slice(&chunk);
|
|
378 |
+ |
}
|
|
379 |
+ |
Ok(None) => break,
|
|
380 |
+ |
Err(e) => return Err(AppError::Storage(format!("read object from S3: {e}"))),
|
|
381 |
+ |
}
|
|
382 |
+ |
}
|
|
383 |
+ |
Ok(buf.freeze())
|
|
384 |
+ |
}
|
|
385 |
+ |
|
| 356 |
386 |
|
/// Abstract storage backend — implemented by `S3Client` (production) and
|
| 357 |
387 |
|
/// `InMemoryStorage` (tests). Routes access storage through this trait.
|
| 358 |
388 |
|
#[async_trait::async_trait]
|
| 374 |
404 |
|
/// drive the stream to disk (scanner spool) or to a layer that consumes
|
| 375 |
405 |
|
/// chunks directly.
|
| 376 |
406 |
|
async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream>;
|
|
407 |
+ |
/// Download into memory like [`download_object_buf`], but abort if the body
|
|
408 |
+ |
/// exceeds `max_bytes`. The scanner routes files it *recorded* as small to an
|
|
409 |
+ |
/// in-memory branch, but `file_size_bytes` is asserted at upload time and can
|
|
410 |
+ |
/// under-report the real object; this bounds the aggregation so a mis-recorded
|
|
411 |
+ |
/// or abusive object can't pull an unbounded body into RAM — the independent
|
|
412 |
+ |
/// ceiling the spool path already enforces (Run 22 Perf). Streams via
|
|
413 |
+ |
/// `download_stream`, so no backend can hand back the whole body up front.
|
|
414 |
+ |
async fn download_object_buf_capped(&self, s3_key: &str, max_bytes: u64) -> Result<bytes::Bytes> {
|
|
415 |
+ |
let stream = self.download_stream(s3_key).await?;
|
|
416 |
+ |
read_bytestream_capped(stream, s3_key, max_bytes).await
|
|
417 |
+ |
}
|
| 377 |
418 |
|
/// Read up to the first `len` bytes of an object. Production overrides this
|
| 378 |
419 |
|
/// with a ranged `GetObject` so a content sniff transfers only the header,
|
| 379 |
420 |
|
/// not the whole object. The default streams and stops early — correct, but
|
| 987 |
1028 |
|
mod tests {
|
| 988 |
1029 |
|
use super::*;
|
| 989 |
1030 |
|
|
|
1031 |
+ |
#[tokio::test]
|
|
1032 |
+ |
async fn capped_read_aborts_when_body_exceeds_cap() {
|
|
1033 |
+ |
// A recorded-small object whose real body is larger must not aggregate
|
|
1034 |
+ |
// past the cap (Run 22 Perf — the buffered scan-download ceiling).
|
|
1035 |
+ |
let stream = s3_storage::ByteStream::from(vec![0u8; 100]);
|
|
1036 |
+ |
let err = read_bytestream_capped(stream, "k", 50).await.unwrap_err();
|
|
1037 |
+ |
assert!(matches!(err, AppError::Storage(_)), "expected Storage error, got {err:?}");
|
|
1038 |
+ |
}
|
|
1039 |
+ |
|
|
1040 |
+ |
#[tokio::test]
|
|
1041 |
+ |
async fn capped_read_allows_body_within_cap() {
|
|
1042 |
+ |
let stream = s3_storage::ByteStream::from(vec![7u8; 40]);
|
|
1043 |
+ |
let out = read_bytestream_capped(stream, "k", 50).await.unwrap();
|
|
1044 |
+ |
assert_eq!(out.len(), 40);
|
|
1045 |
+ |
assert!(out.iter().all(|&b| b == 7));
|
|
1046 |
+ |
}
|
|
1047 |
+ |
|
|
1048 |
+ |
#[tokio::test]
|
|
1049 |
+ |
async fn capped_read_allows_body_exactly_at_cap() {
|
|
1050 |
+ |
// The abort condition is strictly `>`, so a body equal to the cap passes.
|
|
1051 |
+ |
let stream = s3_storage::ByteStream::from(vec![1u8; 50]);
|
|
1052 |
+ |
let out = read_bytestream_capped(stream, "k", 50).await.unwrap();
|
|
1053 |
+ |
assert_eq!(out.len(), 50);
|
|
1054 |
+ |
}
|
|
1055 |
+ |
|
| 990 |
1056 |
|
#[test]
|
| 991 |
1057 |
|
fn extract_key_cdn_form() {
|
| 992 |
1058 |
|
let key = extract_s3_key_from_url(
|