fix(offline): reject truncated downloads when Content-Length is known

Fail closed on short bodies and empty payloads so partial files never
become complete cache entries.
This commit is contained in:
Afonso Jorge Ramos committed 2026-07-12 23:56:59 +02:00
1 parent a9949b8cf2
commit f507b5c214
1 file changed
+51
@@ -113,6 +113,27 @@ impl StreamFetcher {
Err(last_error)
}
/// Validate downloaded byte count against optional Content-Length.
/// Fail closed when length is known and mismatched, or when zero bytes.
pub(crate) fn validate_download_size(
track_id: u64,
cached: u64,
content_length: Option<u64>,
) -> Result<(), String> {
if let Some(expected) = content_length {
if cached != expected {
return Err(format!(
"Truncated download for track {track_id}: got {cached} bytes, Content-Length was {expected}"
));
}
}
if cached == 0 {
return Err(format!("Empty download for track {track_id}"));
}
Ok(())
}
/// Single download attempt: stream response body to a temp file.
async fn try_download(
&self,
@@ -214,6 +235,8 @@ impl StreamFetcher {
.map_err(|e| format!("Failed to flush file: {}", e))?;
drop(file);
validate_download_size(track_id, cached, total_size)?;
Ok(cached)
}
@@ -707,3 +730,31 @@ pub fn spawn_track_cache_download(
}
});
}
#[cfg(test)]
mod download_size_tests {
use super::validate_download_size;
#[test]
fn accepts_matching_length() {
assert!(validate_download_size(1, 100, Some(100)).is_ok());
}
#[test]
fn rejects_truncated() {
let err = validate_download_size(7, 50, Some(100)).unwrap_err();
assert!(err.contains("Truncated"));
assert!(err.contains("50"));
assert!(err.contains("100"));
}
#[test]
fn rejects_empty_even_without_length() {
assert!(validate_download_size(2, 0, None).is_err());
}
#[test]
fn accepts_nonzero_unknown_length() {
assert!(validate_download_size(3, 42, None).is_ok());
}
}