Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions crates/snapshots/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ use std::{

use eyre::Result;
use lz4::Decoder;
use reqwest::{blocking::Client as BlockingClient, header::RANGE, Client, StatusCode};
use reqwest::{
blocking::Client as BlockingClient,
header::{CONTENT_RANGE, RANGE},
Client, StatusCode,
};
use serde::Deserialize;
use tar::Archive;
use tokio::task;
Expand Down Expand Up @@ -442,7 +446,7 @@ fn parse_total_size(response: &reqwest::blocking::Response) -> Option<u64> {
if response.status() == StatusCode::PARTIAL_CONTENT {
response
.headers()
.get("Content-Range")
.get(CONTENT_RANGE)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split('/').next_back())
.and_then(|v| v.parse().ok())
Expand All @@ -451,6 +455,14 @@ fn parse_total_size(response: &reqwest::blocking::Response) -> Option<u64> {
}
}

fn parse_content_range_start(response: &reqwest::blocking::Response) -> Option<u64> {
let range = response.headers().get(CONTENT_RANGE)?.to_str().ok()?;
let range = range.strip_prefix("bytes ")?;
let (range, _) = range.split_once('/')?;
let (start, _) = range.split_once('-')?;
start.parse().ok()
}

fn open_part_file(part_path: &Path, append: bool) -> Result<std::fs::File> {
if append {
OpenOptions::new()
Expand Down Expand Up @@ -506,6 +518,16 @@ fn attempt_download(client: &BlockingClient, url: &str, part_path: &Path) -> Res
let mut response = request.send().and_then(|r| r.error_for_status())?;

let is_partial = response.status() == StatusCode::PARTIAL_CONTENT;
if is_partial && existing_size > 0 {
let range_start = parse_content_range_start(&response)
.ok_or_else(|| eyre::eyre!("Server did not provide a valid Content-Range header"))?;
if range_start != existing_size {
return Err(eyre::eyre!(
"Server returned Content-Range starting at {range_start}, expected {existing_size}"
));
}
}

let total = parse_total_size(&response).ok_or_else(|| {
eyre::eyre!("Server did not provide Content-Length or Content-Range header")
})?;
Expand Down Expand Up @@ -1142,6 +1164,42 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn resumable_download_rejects_mismatched_content_range_start() -> Result<()> {
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

let server = MockServer::start().await;
let url = format!("{}/snap/consensus.tar.lz4", server.uri());
let dir = tempfile::tempdir()?;
let (part_path, _) = seed_partial_download(dir.path(), &url, b"prefix-")?;
Mock::given(method("GET"))
.and(path("/snap/consensus.tar.lz4"))
.and(header("range", "bytes=7-"))
.respond_with(
ResponseTemplate::new(206)
.set_body_bytes(b"pref".to_vec())
.append_header("Content-Range", "bytes 0-3/11"),
)
.expect(1)
.mount(&server)
.await;

let download_part_path = part_path.clone();
let result = tokio::task::spawn_blocking(move || {
let client = BlockingClient::new();
attempt_download(&client, &url, &download_part_path)
})
.await?;

assert!(
result.is_err(),
"mismatched Content-Range should not append to the existing .part file"
);
assert_eq!(std::fs::read(part_path)?, b"prefix-");
Ok(())
}

#[tokio::test]
async fn resumable_download_resumes_with_a_refreshed_signature() -> Result<()> {
use wiremock::matchers::{header, method, path};
Expand Down