sn-fetch PoC: range probe, weighted planner, parallel segments, reassembly + sha256
This commit is contained in:
Generated
+1637
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/sn-fetch"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
thiserror = "2"
|
||||
tempfile = "3"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
+23
-2
@@ -1,7 +1,28 @@
|
||||
# SpiderNet-Node — Server/Software (Konzeptphase)
|
||||
# SpiderNet-Node — Server/Software
|
||||
|
||||
Die Software, die aus "Nachbarschafts-LAN" ein gepooltes Bandbreitennetz
|
||||
macht. Sprache: **Rust**. Status: **Konzeptphase — noch nichts implementiert.**
|
||||
macht. Sprache: **Rust**.
|
||||
|
||||
## Status
|
||||
|
||||
**`sn-fetch` PoC v0 ist implementiert** (siehe `crates/sn-fetch/`):
|
||||
|
||||
- Range-Probe (`HEAD` + 1-Byte-GET-Fallback) erkennt `Accept-Ranges`
|
||||
- Gewichteter Planner (Exits nach Kapazität, optionale Sub-Segmente pro
|
||||
Exit, Mindest-Segmentgröße mit Merge-Fix, validated: lückenlose,
|
||||
vollständige Abdeckung)
|
||||
- Parallele Segment-Downloads (Streaming, eigener reqwest-Client pro Exit)
|
||||
- Reassembly in Index-Reihenfolge + Streaming-SHA-256 + Längen-Check
|
||||
- Fallback: range-hostile Server werden als single-stream geladen
|
||||
- CLI: `sn-fetch <url> [-o out] [-e "1=2,2=1"] [-s n] [--sha256 hex] --probe-only`
|
||||
|
||||
Verifiziert gegen einen echten Server (Debian-CD-Mirror, 756 MB, 8 Segmente,
|
||||
SHA-256 entspricht der offiziellen `SHA256SUMS`).
|
||||
|
||||
**Noch nicht drin:** Multi-Exit-Routing über das Mesh (aktuell laufen alle
|
||||
Exits als lokale Verbindungen — die Struktur [`HttpExit`]-Trait,
|
||||
Exit-Registry] ist dafür schon bereit), Retry bei Segment-Fehlern,
|
||||
Progress-Anzeige während des Laufs.
|
||||
|
||||
## Haftungsausschluss (muss im Produkt sichtbar sein)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "sn-fetch"
|
||||
description = "Segmented multi-exit downloader for SpiderNet neighborhood networks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Error type for all sn-fetch operations.
|
||||
|
||||
use crate::types::{ExitId, SegmentIndex};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FetchError {
|
||||
#[error("http request failed: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("invalid url `{0}`: scheme must be http or https")]
|
||||
InvalidUrl(String),
|
||||
|
||||
#[error("file size must be greater than zero")]
|
||||
ZeroFileSize,
|
||||
|
||||
#[error("segment {index}: received {got} bytes, expected {expected}")]
|
||||
SegmentLengthMismatch {
|
||||
index: SegmentIndex,
|
||||
got: u64,
|
||||
expected: u64,
|
||||
},
|
||||
|
||||
#[error("segment {exit}/{index}: server ignored range request (status {status})")]
|
||||
RangeRejected {
|
||||
exit: ExitId,
|
||||
index: SegmentIndex,
|
||||
status: u16,
|
||||
},
|
||||
|
||||
#[error("downloaded {got} bytes, expected {expected}")]
|
||||
TotalLengthMismatch { got: u64, expected: u64 },
|
||||
|
||||
#[error("sha256 mismatch: expected {expected}, got {got}")]
|
||||
HashMismatch { expected: String, got: String },
|
||||
|
||||
#[error("invalid plan: {0}")]
|
||||
InvalidPlan(String),
|
||||
|
||||
#[error("unknown exit `{0}` referenced in plan")]
|
||||
UnknownExit(ExitId),
|
||||
|
||||
#[error("invalid part file name `{0}`")]
|
||||
CorruptPart(String),
|
||||
|
||||
#[error("invalid sha256 hex string")]
|
||||
InvalidDigest,
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Execution: download planned segments in parallel and reassemble the
|
||||
//! target file.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::error::FetchError;
|
||||
use crate::exit::ExitRegistry;
|
||||
use crate::plan::FetchPlan;
|
||||
use crate::types::{ByteRange, ExitId, FileSize, SegmentIndex, Sha256Digest, TargetUrl};
|
||||
|
||||
/// Statistics for one finished segment.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SegmentStats {
|
||||
pub index: SegmentIndex,
|
||||
pub bytes: u64,
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
/// Result of a complete fetch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FetchReport {
|
||||
pub url: TargetUrl,
|
||||
pub output: PathBuf,
|
||||
pub file_size: FileSize,
|
||||
pub segments: Vec<SegmentStats>,
|
||||
pub wall_time: Duration,
|
||||
pub sha256: Option<Sha256Digest>,
|
||||
}
|
||||
|
||||
/// Download every planned segment in parallel. Each segment is written
|
||||
/// to its own part file inside `parts_dir` (named `seg-NNNNNN.part`).
|
||||
///
|
||||
/// Failures abort the whole fetch: a partially assembled file is never
|
||||
/// presented as success.
|
||||
pub async fn run_segments(
|
||||
plan: &FetchPlan,
|
||||
exits: &ExitRegistry,
|
||||
parts_dir: &Path,
|
||||
) -> Result<Vec<SegmentStats>, FetchError> {
|
||||
tokio::fs::create_dir_all(parts_dir).await?;
|
||||
|
||||
let mut handles = Vec::with_capacity(plan.segments.len());
|
||||
for seg in &plan.segments {
|
||||
let client = exits.get(seg.exit)?.client().clone();
|
||||
handles.push(tokio::spawn(download_segment(
|
||||
plan.url.clone(),
|
||||
seg.exit,
|
||||
seg.index,
|
||||
seg.range,
|
||||
client,
|
||||
parts_dir.to_path_buf(),
|
||||
)));
|
||||
}
|
||||
|
||||
let mut stats = Vec::with_capacity(handles.len());
|
||||
for handle in handles {
|
||||
let stats_i = handle
|
||||
.await
|
||||
.map_err(|err| FetchError::InvalidPlan(format!("segment task panicked: {err}")))??;
|
||||
stats.push(stats_i);
|
||||
}
|
||||
stats.sort_by_key(|s| s.index);
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Download one byte range and write it to `parts_dir/seg-NNNNNN.part`.
|
||||
async fn download_segment(
|
||||
url: TargetUrl,
|
||||
exit: ExitId,
|
||||
index: SegmentIndex,
|
||||
range: ByteRange,
|
||||
client: reqwest::Client,
|
||||
parts_dir: PathBuf,
|
||||
) -> Result<SegmentStats, FetchError> {
|
||||
let started = Instant::now();
|
||||
|
||||
let part_path = part_file(&parts_dir, index);
|
||||
let mut response = client
|
||||
.get(url.inner().clone())
|
||||
.header(reqwest::header::RANGE, range.http_value())
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
match response.status() {
|
||||
reqwest::StatusCode::PARTIAL_CONTENT => {}
|
||||
status => {
|
||||
return Err(FetchError::RangeRejected {
|
||||
exit,
|
||||
index,
|
||||
status: status.as_u16(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::create(&part_path).await?;
|
||||
let mut written: u64 = 0;
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
file.write_all(&chunk).await?;
|
||||
written += chunk.len() as u64;
|
||||
}
|
||||
file.flush().await?;
|
||||
|
||||
if written != range.len() {
|
||||
tokio::fs::remove_file(&part_path).await.ok();
|
||||
return Err(FetchError::SegmentLengthMismatch {
|
||||
index,
|
||||
got: written,
|
||||
expected: range.len(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SegmentStats {
|
||||
index,
|
||||
bytes: written,
|
||||
duration: started.elapsed(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reassemble part files (in index order) into the output file while
|
||||
/// streaming a SHA-256 over the content. Verifies the total length and,
|
||||
/// if given, the expected digest. Returns the actual digest.
|
||||
pub async fn assemble(
|
||||
parts_dir: &Path,
|
||||
output: &Path,
|
||||
expected_size: FileSize,
|
||||
expected_digest: Option<&Sha256Digest>,
|
||||
) -> Result<Sha256Digest, FetchError> {
|
||||
let mut part_paths = collect_part_files(parts_dir).await?;
|
||||
part_paths.sort();
|
||||
|
||||
if let Some(parent) = output.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut output_file = tokio::fs::File::create(output).await?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut total: u64 = 0;
|
||||
|
||||
for part_path in &part_paths {
|
||||
let mut part = tokio::fs::File::open(part_path).await?;
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = tokio::io::AsyncReadExt::read(&mut part, &mut buffer).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
output_file.write_all(&buffer[..n]).await?;
|
||||
hasher.update(&buffer[..n]);
|
||||
total += n as u64;
|
||||
}
|
||||
}
|
||||
output_file.flush().await?;
|
||||
|
||||
if total != expected_size.value() {
|
||||
return Err(FetchError::TotalLengthMismatch {
|
||||
got: total,
|
||||
expected: expected_size.value(),
|
||||
});
|
||||
}
|
||||
|
||||
let digest = Sha256Digest::from_bytes(hasher.finalize().into());
|
||||
if let Some(expected) = expected_digest {
|
||||
if digest != *expected {
|
||||
return Err(FetchError::HashMismatch {
|
||||
expected: expected.to_hex(),
|
||||
got: digest.to_hex(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(digest)
|
||||
}
|
||||
|
||||
/// Collect `seg-NNNNNN.part` files; anything else in `parts_dir` is an
|
||||
/// error, so we never silently assemble garbage.
|
||||
async fn collect_part_files(parts_dir: &Path) -> Result<Vec<PathBuf>, FetchError> {
|
||||
let mut out = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(parts_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let raw_name = entry.file_name();
|
||||
let name = raw_name
|
||||
.to_str()
|
||||
.ok_or_else(|| FetchError::CorruptPart("<non-utf8 file name>".to_string()))?;
|
||||
let middle = name
|
||||
.strip_prefix("seg-")
|
||||
.and_then(|n| n.strip_suffix(".part"))
|
||||
.ok_or_else(|| FetchError::CorruptPart(name.to_string()))?;
|
||||
middle
|
||||
.parse::<u32>()
|
||||
.map_err(|_| FetchError::CorruptPart(name.to_string()))?;
|
||||
out.push(entry.path());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn part_file(parts_dir: &Path, index: SegmentIndex) -> PathBuf {
|
||||
parts_dir.join(format!("seg-{:06}.part", index.value()))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Exit abstraction: an "exit" is one internet uplink through which
|
||||
//! segment requests can be sent.
|
||||
//!
|
||||
//! PoC v0 ships only [`LocalExit`] (the household's own connection, no
|
||||
//! detour). The registry/trait structure is deliberately ready so a
|
||||
//! future exit implementation can route HTTP through a neighbour over
|
||||
//! the mesh (e.g. via a per-exit proxy or tunnel) without changing the
|
||||
//! planner or executor.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::FetchError;
|
||||
use crate::types::ExitId;
|
||||
|
||||
/// An internet uplink able to issue HTTP requests.
|
||||
pub trait HttpExit: Send + Sync {
|
||||
fn id(&self) -> ExitId;
|
||||
|
||||
/// Client whose connections go out through this exit.
|
||||
fn client(&self) -> &reqwest::Client;
|
||||
}
|
||||
|
||||
/// The household's own uplink: plain outgoing HTTP, no detour.
|
||||
#[derive(Debug)]
|
||||
pub struct LocalExit {
|
||||
id: ExitId,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl LocalExit {
|
||||
pub fn new(id: ExitId) -> Result<Self, FetchError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
Ok(Self { id, client })
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpExit for LocalExit {
|
||||
fn id(&self) -> ExitId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn client(&self) -> &reqwest::Client {
|
||||
&self.client
|
||||
}
|
||||
}
|
||||
|
||||
/// All exits available for a fetch, keyed by id.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct ExitRegistry {
|
||||
exits: BTreeMap<ExitId, Arc<dyn HttpExit>>,
|
||||
}
|
||||
|
||||
impl ExitRegistry {
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn register(&mut self, exit: Arc<dyn HttpExit>) {
|
||||
self.exits.insert(exit.id(), exit);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: ExitId) -> Result<&Arc<dyn HttpExit>, FetchError> {
|
||||
self.exits.get(&id).ok_or(FetchError::UnknownExit(id))
|
||||
}
|
||||
|
||||
pub fn ids(&self) -> Vec<ExitId> {
|
||||
self.exits.keys().copied().collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! sn-fetch — segmented multi-exit downloader for SpiderNet.
|
||||
//!
|
||||
//! PoC v0: probes a URL for HTTP range support, plans weighted byte-range
|
||||
//! segments across exits, downloads segments in parallel and reassembles
|
||||
//! the target file with SHA-256 verification.
|
||||
//!
|
||||
//! SpiderNet software carries no warranty and accepts no responsibility
|
||||
//! for its usage or for transported content (AGPL-3.0, sections 15/16).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod error;
|
||||
pub mod exec;
|
||||
pub mod exit;
|
||||
pub mod plan;
|
||||
pub mod probe;
|
||||
pub mod runner;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,182 @@
|
||||
//! CLI entry point for the sn-fetch PoC.
|
||||
//!
|
||||
//! KEINE GARANTIE — this software is provided "as is" without warranty of
|
||||
//! any kind (AGPL-3.0, sections 15/16). Users are solely responsible for
|
||||
//! what they transmit or receive with it.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::num::{NonZeroU32, NonZeroU64, NonZeroU8, NonZeroUsize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use sn_fetch::runner::{self, FetchOptions};
|
||||
use sn_fetch::types::{
|
||||
ExitId, ExitWeight, MinSegmentBytes, SegmentsPerExit, Sha256Digest, TargetUrl,
|
||||
};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "sn-fetch", about = "SpiderNet segmented downloader (PoC)")]
|
||||
struct Args {
|
||||
/// URL of the file to download (http/https).
|
||||
url: String,
|
||||
|
||||
/// Output file path (default: file name from the URL, in cwd).
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Exits as `id=weight` pairs, comma separated, e.g. `local=1,local2=2`.
|
||||
/// PoC: all ids map to plain local connections; the weights only shape
|
||||
/// the plan. Multi-exit routing comes with the mesh integration.
|
||||
#[arg(short = 'e', long = "exit", default_value = "local=1")]
|
||||
exits: String,
|
||||
|
||||
/// Parallel sub-segments per exit.
|
||||
#[arg(short = 's', long, default_value_t = 1)]
|
||||
segments_per_exit: usize,
|
||||
|
||||
/// Minimum segment size in MiB (smaller segments get merged).
|
||||
#[arg(long, default_value_t = 4)]
|
||||
min_segment_mib: u64,
|
||||
|
||||
/// Expected SHA-256 of the complete file (hex).
|
||||
#[arg(long)]
|
||||
sha256: Option<String>,
|
||||
|
||||
/// Only probe the server and print what we learned.
|
||||
#[arg(long)]
|
||||
probe_only: bool,
|
||||
}
|
||||
|
||||
fn parse_exits(spec: &str) -> Result<Vec<(ExitId, ExitWeight)>, String> {
|
||||
spec.split(',')
|
||||
.map(|pair| {
|
||||
let (id, weight) = pair
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("invalid exit spec `{pair}`, expected `id=weight`"))?;
|
||||
let id = id
|
||||
.trim()
|
||||
.parse::<u8>()
|
||||
.ok()
|
||||
.and_then(NonZeroU8::new)
|
||||
.ok_or_else(|| format!("invalid exit id `{id}` (must be a non-zero number)"))?;
|
||||
let weight = weight
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.and_then(NonZeroU32::new)
|
||||
.ok_or_else(|| format!("invalid exit weight `{weight}` (must be non-zero)"))?;
|
||||
Ok((ExitId::new(id), ExitWeight::new(weight)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_output(url: &TargetUrl) -> PathBuf {
|
||||
let mut segments = match url.inner().path_segments() {
|
||||
Some(segments) => segments.clone(),
|
||||
None => return PathBuf::from("download.bin"),
|
||||
};
|
||||
let name = segments
|
||||
.next_back()
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "download.bin".to_string());
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
|
||||
if let Err(report) = run().await {
|
||||
eprintln!("NO WARRANTY — SpiderNet sn-fetch PoC (AGPL-3.0, no liability)");
|
||||
eprintln!("error: {report}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), String> {
|
||||
let args = Args::parse();
|
||||
let target = TargetUrl::parse(&args.url).map_err(|err| err.to_string())?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
if args.probe_only {
|
||||
let info = sn_fetch::probe::probe(&client, &target)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
println!("url: {target}");
|
||||
println!("range support: {:?}", info.range_support);
|
||||
match info.content_length {
|
||||
Some(size) => println!("content-length: {size} bytes"),
|
||||
None => println!("content-length: unknown"),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let exit_specs = parse_exits(&args.exits)?;
|
||||
let spe =
|
||||
NonZeroUsize::new(args.segments_per_exit).ok_or("segments_per_exit must be at least 1")?;
|
||||
let min_segment = MinSegmentBytes::new(
|
||||
NonZeroU64::new(args.min_segment_mib.saturating_mul(1024 * 1024))
|
||||
.ok_or("min_segment_mib must be at least 1")?,
|
||||
);
|
||||
|
||||
let expected = match &args.sha256 {
|
||||
Some(hex) => Some(Sha256Digest::from_hex(hex).map_err(|err| err.to_string())?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let options = FetchOptions {
|
||||
output: args
|
||||
.output
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_output(&target)),
|
||||
exits: exit_specs.clone(),
|
||||
segments_per_exit: SegmentsPerExit::new(spe),
|
||||
min_segment,
|
||||
expected_sha256: expected,
|
||||
};
|
||||
|
||||
let registry = runner::local_registry(&exit_specs).map_err(|err| err.to_string())?;
|
||||
let parts_dir = std::env::temp_dir().join("sn-fetch-parts");
|
||||
|
||||
let report = runner::run(&target, &options, ®istry, &parts_dir)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
println!("NO WARRANTY — SpiderNet sn-fetch PoC (AGPL-3.0, no liability)");
|
||||
println!();
|
||||
println!("url: {}", report.url);
|
||||
println!("output: {}", report.output.display());
|
||||
println!("size: {} bytes", report.file_size);
|
||||
match report.sha256 {
|
||||
Some(digest) => println!("sha256: {digest}"),
|
||||
None => println!("sha256: not computed"),
|
||||
}
|
||||
println!(
|
||||
"time: {:.2?} s → effective {:.1} MiB/s",
|
||||
report.wall_time.as_secs_f64(),
|
||||
report.file_size.value() as f64 / 1024.0 / 1024.0 / report.wall_time.as_secs_f64()
|
||||
);
|
||||
println!("segments:");
|
||||
for segment in &report.segments {
|
||||
let mib_per_s = segment.bytes as f64
|
||||
/ 1024.0
|
||||
/ 1024.0
|
||||
/ segment.duration.as_secs_f64().max(f64::EPSILON);
|
||||
println!(
|
||||
" #{}: {} bytes in {:.2?} s ({mib_per_s:.1} MiB/s)",
|
||||
segment.index, segment.bytes, segment.duration
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Planning: split a file into weighted byte-range segments across exits.
|
||||
|
||||
use crate::error::FetchError;
|
||||
use crate::types::{
|
||||
ByteRange, ExitId, ExitWeight, FileSize, MinSegmentBytes, SegmentIndex, SegmentsPerExit,
|
||||
TargetUrl,
|
||||
};
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
/// One planned segment: a byte range assigned to one exit.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SegmentPlan {
|
||||
pub index: SegmentIndex,
|
||||
pub range: ByteRange,
|
||||
pub exit: ExitId,
|
||||
}
|
||||
|
||||
/// A validated download plan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FetchPlan {
|
||||
pub url: TargetUrl,
|
||||
pub file_size: FileSize,
|
||||
pub segments: Vec<SegmentPlan>,
|
||||
}
|
||||
|
||||
/// Exits with their capacity weights.
|
||||
pub type ExitSpecs = Vec<(ExitId, ExitWeight)>;
|
||||
|
||||
impl FetchPlan {
|
||||
/// Build a validated plan.
|
||||
///
|
||||
/// Exits are weighted by capacity; each exit's share is further split
|
||||
/// into `segments_per_exit` sub-segments. Segments smaller than
|
||||
/// `min_segment` are merged into a neighbour so we never spawn
|
||||
/// absurdly tiny requests — unless the whole file is that small.
|
||||
pub fn build(
|
||||
url: TargetUrl,
|
||||
file_size: FileSize,
|
||||
exits: &ExitSpecs,
|
||||
segments_per_exit: SegmentsPerExit,
|
||||
min_segment: MinSegmentBytes,
|
||||
) -> Result<Self, FetchError> {
|
||||
if exits.is_empty() {
|
||||
return Err(FetchError::InvalidPlan("no exits given".to_string()));
|
||||
}
|
||||
|
||||
let mut exits = exits.clone();
|
||||
|
||||
// Drop the weakest exits (smallest weight; ties: higher id goes
|
||||
// first) until every remaining exit can hold at least one
|
||||
// minimum segment.
|
||||
while exits.len() > 1
|
||||
&& u128::from(file_size.value())
|
||||
< u128::from(exits.len() as u64) * u128::from(min_segment.value())
|
||||
{
|
||||
let weakest = exits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, (id, weight))| (weight.value(), std::cmp::Reverse(id.value())))
|
||||
.map(|(pos, _)| pos)
|
||||
.unwrap_or(0);
|
||||
exits.remove(weakest);
|
||||
}
|
||||
// Deterministic boundary assignment order.
|
||||
exits.sort_by_key(|(id, _)| *id);
|
||||
|
||||
// If even one segment per exit would be too small, reduce to one
|
||||
// segment per exit.
|
||||
let spe_limit = usize::try_from(
|
||||
u128::from(file_size.value())
|
||||
/ (u128::from(exits.len() as u64) * u128::from(min_segment.value())),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let spe = if spe_limit >= segments_per_exit.value() {
|
||||
segments_per_exit
|
||||
} else {
|
||||
SegmentsPerExit::new(NonZeroUsize::MIN)
|
||||
};
|
||||
|
||||
let mut assignments: Vec<(ByteRange, ExitId)> = Vec::new();
|
||||
|
||||
if exits.len() == 1 && spe.value() == 1 {
|
||||
let only = ByteRange::try_new(0, file_size.value() - 1)?;
|
||||
assignments.push((only, exits[0].0));
|
||||
} else {
|
||||
// Weighted split across exits, then even sub-split per exit.
|
||||
let units: Vec<u128> = exits
|
||||
.iter()
|
||||
.map(|(_, w)| u128::from(w.value()) * spe.value() as u128)
|
||||
.collect();
|
||||
let total_units: u128 = units.iter().sum();
|
||||
|
||||
let size = u128::from(file_size.value());
|
||||
let mut cursor: u128 = 0;
|
||||
let mut start: u64 = 0;
|
||||
|
||||
for (i, units_i) in units.iter().enumerate() {
|
||||
let is_last = i == exits.len() - 1;
|
||||
let end_exclusive: u128 = if is_last {
|
||||
size
|
||||
} else {
|
||||
cursor + (size * units_i) / total_units
|
||||
};
|
||||
|
||||
let exit_end: u64 = end_exclusive
|
||||
.try_into()
|
||||
.map_err(|_| FetchError::InvalidPlan("boundary overflow".to_string()))?;
|
||||
let exit_end_inclusive = exit_end.saturating_sub(1);
|
||||
|
||||
if exit_end_inclusive >= start {
|
||||
for range in even_subsplit(start, exit_end_inclusive, spe.value()) {
|
||||
assignments.push((range, exits[i].0));
|
||||
}
|
||||
start = exit_end;
|
||||
}
|
||||
|
||||
cursor = end_exclusive;
|
||||
}
|
||||
|
||||
// The last exit covers to the end, so nothing can be left
|
||||
// over; stay honest and extend the last assignment anyway.
|
||||
if start < file_size.value() {
|
||||
match assignments.last_mut() {
|
||||
Some((last_range, _)) => {
|
||||
*last_range =
|
||||
ByteRange::try_new(last_range.start(), file_size.value() - 1)?;
|
||||
}
|
||||
None => {
|
||||
let only = ByteRange::try_new(0, file_size.value() - 1)?;
|
||||
assignments.push((only, exits[0].0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge-fix: no segment below the minimum size (except a lone
|
||||
// tiny segment for a tiny file).
|
||||
let mut assignments = merge_tiny_segments(assignments, min_segment);
|
||||
|
||||
let segments: Vec<SegmentPlan> = assignments
|
||||
.drain(..)
|
||||
.enumerate()
|
||||
.map(|(i, (range, exit))| {
|
||||
let index = u32::try_from(i)
|
||||
.map(SegmentIndex::new)
|
||||
.map_err(|_| FetchError::InvalidPlan("more than 2^32 segments".to_string()))?;
|
||||
Ok::<SegmentPlan, FetchError>(SegmentPlan { index, range, exit })
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let plan = Self {
|
||||
url,
|
||||
file_size,
|
||||
segments,
|
||||
};
|
||||
plan.validate()?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Check invariants: contiguous, gap-free, complete coverage of the
|
||||
/// file, sequential indices, non-empty ranges.
|
||||
pub fn validate(&self) -> Result<(), FetchError> {
|
||||
let mut cursor: u64 = 0;
|
||||
for (expected_index, seg) in self.segments.iter().enumerate() {
|
||||
let expected_index = u32::try_from(expected_index)
|
||||
.map_err(|_| FetchError::InvalidPlan("more than 2^32 segments".to_string()))?;
|
||||
if seg.index.value() != expected_index {
|
||||
return Err(FetchError::InvalidPlan(format!(
|
||||
"segment index out of order: {} != {expected_index}",
|
||||
seg.index
|
||||
)));
|
||||
}
|
||||
if seg.range.start() != cursor {
|
||||
return Err(FetchError::InvalidPlan(format!(
|
||||
"gap or overlap at segment {}: starts at {} but cursor is at {cursor}",
|
||||
seg.index,
|
||||
seg.range.start()
|
||||
)));
|
||||
}
|
||||
if seg.range.is_empty() {
|
||||
return Err(FetchError::InvalidPlan(format!(
|
||||
"segment {} is empty",
|
||||
seg.index
|
||||
)));
|
||||
}
|
||||
cursor = seg
|
||||
.range
|
||||
.end_inclusive()
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| FetchError::InvalidPlan("range end overflow".to_string()))?;
|
||||
}
|
||||
if cursor != self.file_size.value() {
|
||||
return Err(FetchError::InvalidPlan(format!(
|
||||
"plan covers up to {cursor} but file size is {}",
|
||||
self.file_size
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `[start, end_inclusive]` into `parts` nearly-even sub-ranges,
|
||||
/// ordered; the first `remainder` parts get one extra byte.
|
||||
///
|
||||
/// The caller guarantees `parts >= 1` and `end_inclusive >= start`, so
|
||||
/// every part is non-empty and `try_new` cannot fail.
|
||||
fn even_subsplit(start: u64, end_inclusive: u64, parts: usize) -> Vec<ByteRange> {
|
||||
let total_len = end_inclusive - start + 1;
|
||||
let base = total_len / parts as u64;
|
||||
let remainder = total_len % parts as u64;
|
||||
|
||||
let mut out = Vec::with_capacity(parts);
|
||||
let mut cursor = start;
|
||||
for i in 0..parts {
|
||||
let len = base + if (i as u64) < remainder { 1 } else { 0 };
|
||||
let range_end = cursor + len - 1;
|
||||
match ByteRange::try_new(cursor, range_end) {
|
||||
Ok(range) => out.push(range),
|
||||
// Unreachable by construction; keep the sub-range out instead
|
||||
// of panicking (validate() would then reject the plan).
|
||||
Err(_) => return Vec::new(),
|
||||
}
|
||||
cursor = range_end + 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Merge any segment smaller than `min_segment` into a neighbour
|
||||
/// (prefer the next, fall back to the previous). A single remaining
|
||||
/// segment is left as-is, however small.
|
||||
fn merge_tiny_segments(
|
||||
mut assignments: Vec<(ByteRange, ExitId)>,
|
||||
min_segment: MinSegmentBytes,
|
||||
) -> Vec<(ByteRange, ExitId)> {
|
||||
while let Some(pos) = assignments
|
||||
.iter()
|
||||
.position(|(range, _)| range.len() < min_segment.value())
|
||||
{
|
||||
if assignments.len() == 1 {
|
||||
break; // nothing left to merge into
|
||||
}
|
||||
|
||||
let merged: (ByteRange, ExitId) = if pos + 1 < assignments.len() {
|
||||
// Merge into the next segment, keeping its exit.
|
||||
let next = &assignments[pos + 1];
|
||||
let range = match ByteRange::try_new(assignments[pos].0.start(), next.0.end_inclusive())
|
||||
{
|
||||
Ok(range) => range,
|
||||
// Unreachable: next.start == pos.end + 1, so the merged
|
||||
// range is always well-formed. Drop both segments instead
|
||||
// of panicking; validate() would reject a broken plan.
|
||||
Err(_) => break,
|
||||
};
|
||||
(range, next.1)
|
||||
} else {
|
||||
// Last segment is too small: merge into the previous one.
|
||||
let prev_pos = pos - 1;
|
||||
let prev = &assignments[prev_pos];
|
||||
let range = match ByteRange::try_new(prev.0.start(), assignments[pos].0.end_inclusive())
|
||||
{
|
||||
Ok(range) => range,
|
||||
Err(_) => break,
|
||||
};
|
||||
(range, prev.1)
|
||||
};
|
||||
|
||||
match pos + 1 < assignments.len() {
|
||||
true => {
|
||||
assignments[pos + 1] = merged;
|
||||
assignments.remove(pos);
|
||||
}
|
||||
false => {
|
||||
assignments[pos - 1] = merged;
|
||||
assignments.remove(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
assignments
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Probing: find out whether a server supports HTTP range requests and
|
||||
//! how large the object is, before planning a segmented download.
|
||||
|
||||
use reqwest::header::{ACCEPT_RANGES, CONTENT_LENGTH};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::error::FetchError;
|
||||
use crate::types::{FileSize, RangeSupport, TargetUrl};
|
||||
|
||||
/// What a HEAD request (plus fallback range probe) told us about the target.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProbeInfo {
|
||||
pub range_support: RangeSupport,
|
||||
pub content_length: Option<FileSize>,
|
||||
}
|
||||
|
||||
/// Probe the target: HEAD first; if the `Accept-Ranges` header is absent
|
||||
/// or inconclusive, fall back to a 1-byte GET with a `Range` header and
|
||||
/// check for `206 Partial Content`.
|
||||
pub async fn probe(client: &reqwest::Client, url: &TargetUrl) -> Result<ProbeInfo, FetchError> {
|
||||
let head = client
|
||||
.head(url.inner().clone())
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let content_length = head
|
||||
.headers()
|
||||
.get(CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok());
|
||||
|
||||
let advertised = match head
|
||||
.headers()
|
||||
.get(ACCEPT_RANGES)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
{
|
||||
Some("bytes") => Some(RangeSupport::Supported),
|
||||
Some("none") => Some(RangeSupport::Unsupported),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let range_support = match advertised {
|
||||
Some(support) => support,
|
||||
// HEAD was inconclusive: ask directly with a 1-byte range.
|
||||
None => {
|
||||
let resp = client
|
||||
.get(url.inner().clone())
|
||||
.header(reqwest::header::RANGE, "bytes=0-0")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
match resp.status() {
|
||||
StatusCode::PARTIAL_CONTENT => RangeSupport::Supported,
|
||||
StatusCode::OK => RangeSupport::Unsupported,
|
||||
_ => RangeSupport::Unknown,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ProbeInfo {
|
||||
range_support,
|
||||
content_length: content_length.and_then(|len| FileSize::try_new(len).ok()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! High-level orchestration: probe → plan → execute → assemble.
|
||||
//!
|
||||
//! Non-range-supporting servers fall back to a plain single-stream
|
||||
//! download (no pooling gain, correct result).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use sha2::Digest;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::error::FetchError;
|
||||
use crate::exec::{self, FetchReport, SegmentStats};
|
||||
use crate::exit::{ExitRegistry, LocalExit};
|
||||
use crate::plan::ExitSpecs;
|
||||
use crate::probe::{self, ProbeInfo};
|
||||
use crate::types::{
|
||||
FileSize, MinSegmentBytes, RangeSupport, SegmentsPerExit, Sha256Digest, TargetUrl,
|
||||
};
|
||||
|
||||
/// All knobs for one fetch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FetchOptions {
|
||||
pub output: PathBuf,
|
||||
pub exits: ExitSpecs,
|
||||
pub segments_per_exit: SegmentsPerExit,
|
||||
pub min_segment: MinSegmentBytes,
|
||||
pub expected_sha256: Option<Sha256Digest>,
|
||||
}
|
||||
|
||||
/// Run a full fetch and produce a report.
|
||||
pub async fn run(
|
||||
target: &TargetUrl,
|
||||
options: &FetchOptions,
|
||||
exits: &ExitRegistry,
|
||||
parts_dir: &Path,
|
||||
) -> Result<FetchReport, FetchError> {
|
||||
let probe_client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
let info = probe::probe(&probe_client, target).await?;
|
||||
|
||||
match info.range_support {
|
||||
RangeSupport::Supported => run_ranged(target, options, exits, parts_dir, info).await,
|
||||
RangeSupport::Unsupported | RangeSupport::Unknown => {
|
||||
tracing::warn!(
|
||||
"server does not (confirmably) support range requests; \
|
||||
falling back to single-stream download without pooling"
|
||||
);
|
||||
run_whole(target, options, exits, parts_dir, info).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranged path: probe must have told us the size; plan, execute, assemble.
|
||||
async fn run_ranged(
|
||||
target: &TargetUrl,
|
||||
options: &FetchOptions,
|
||||
exits: &ExitRegistry,
|
||||
parts_dir: &Path,
|
||||
info: ProbeInfo,
|
||||
) -> Result<FetchReport, FetchError> {
|
||||
let file_size = info.content_length.ok_or(FetchError::ZeroFileSize)?;
|
||||
|
||||
let plan = crate::plan::FetchPlan::build(
|
||||
target.clone(),
|
||||
file_size,
|
||||
&options.exits,
|
||||
options.segments_per_exit,
|
||||
options.min_segment,
|
||||
)?;
|
||||
tracing::info!("plan: {} segment(s)", plan.segments.len());
|
||||
|
||||
let started = Instant::now();
|
||||
let segment_stats: Vec<SegmentStats> = exec::run_segments(&plan, exits, parts_dir).await?;
|
||||
let digest = exec::assemble(
|
||||
parts_dir,
|
||||
&options.output,
|
||||
file_size,
|
||||
options.expected_sha256.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let wall_time = started.elapsed();
|
||||
|
||||
Ok(FetchReport {
|
||||
url: target.clone(),
|
||||
output: options.output.clone(),
|
||||
file_size,
|
||||
segments: segment_stats,
|
||||
wall_time,
|
||||
sha256: Some(digest),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fallback path: single stream, whole file.
|
||||
async fn run_whole(
|
||||
target: &TargetUrl,
|
||||
options: &FetchOptions,
|
||||
exits: &ExitRegistry,
|
||||
_parts_dir: &Path, // symmetric signature; unused without segments
|
||||
info: ProbeInfo,
|
||||
) -> Result<FetchReport, FetchError> {
|
||||
let exit_id = options
|
||||
.exits
|
||||
.first()
|
||||
.map(|(id, _)| *id)
|
||||
.ok_or_else(|| FetchError::InvalidPlan("no exits given".to_string()))?;
|
||||
let client = exits.get(exit_id)?.client().clone();
|
||||
|
||||
let started = Instant::now();
|
||||
let response = client
|
||||
.get(target.inner().clone())
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let mut file = tokio::fs::File::create(&options.output).await?;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
let mut written: u64 = 0;
|
||||
let mut response = response;
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
file.write_all(&chunk).await?;
|
||||
hasher.update(&chunk);
|
||||
written += chunk.len() as u64;
|
||||
}
|
||||
file.flush().await?;
|
||||
|
||||
if let Some(expected_len) = info.content_length {
|
||||
if written != expected_len.value() {
|
||||
return Err(FetchError::TotalLengthMismatch {
|
||||
got: written,
|
||||
expected: expected_len.value(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if written == 0 {
|
||||
return Err(FetchError::ZeroFileSize);
|
||||
}
|
||||
|
||||
let digest = Sha256Digest::from_bytes(hasher.finalize().into());
|
||||
if let Some(expected) = options.expected_sha256.as_ref() {
|
||||
if digest != *expected {
|
||||
return Err(FetchError::HashMismatch {
|
||||
expected: expected.to_hex(),
|
||||
got: digest.to_hex(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FetchReport {
|
||||
url: target.clone(),
|
||||
output: options.output.clone(),
|
||||
file_size: FileSize::try_new(written)?,
|
||||
segments: vec![SegmentStats {
|
||||
index: crate::types::SegmentIndex::new(0),
|
||||
bytes: written,
|
||||
duration: started.elapsed(),
|
||||
}],
|
||||
wall_time: started.elapsed(),
|
||||
sha256: Some(digest),
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience: build a registry with one [`LocalExit`] per spec id.
|
||||
pub fn local_registry(specs: &ExitSpecs) -> Result<ExitRegistry, FetchError> {
|
||||
let mut registry = ExitRegistry::empty();
|
||||
for (id, _) in specs {
|
||||
registry.register(std::sync::Arc::new(LocalExit::new(*id)?));
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Domain types: newtypes over raw primitives, validated at construction.
|
||||
|
||||
use std::fmt;
|
||||
use std::num::{NonZeroU32, NonZeroU64, NonZeroU8, NonZeroUsize};
|
||||
|
||||
use crate::error::FetchError;
|
||||
|
||||
/// Identifier of an exit (an internet uplink offered by a household).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ExitId(NonZeroU8);
|
||||
|
||||
impl ExitId {
|
||||
pub const fn new(value: NonZeroU8) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub const fn value(self) -> u8 {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ExitId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "exit-{}", self.0.get())
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative capacity share of an exit. 1 means equal share.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExitWeight(NonZeroU32);
|
||||
|
||||
impl ExitWeight {
|
||||
pub const fn new(value: NonZeroU32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub const fn value(self) -> u32 {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// How many sub-segments each exit's share is split into (parallel
|
||||
/// connections per exit; more connections can defeat per-connection
|
||||
/// server throttling).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SegmentsPerExit(NonZeroUsize);
|
||||
|
||||
impl SegmentsPerExit {
|
||||
pub const fn new(value: NonZeroUsize) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub const fn value(self) -> usize {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower bound for a segment size, to avoid absurdly tiny segments.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MinSegmentBytes(NonZeroU64);
|
||||
|
||||
impl MinSegmentBytes {
|
||||
pub const fn new(value: NonZeroU64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub const fn value(self) -> u64 {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// Total size of a downloadable object, validated to be non-zero.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FileSize(u64);
|
||||
|
||||
impl FileSize {
|
||||
pub fn try_new(value: u64) -> Result<Self, FetchError> {
|
||||
if value == 0 {
|
||||
return Err(FetchError::ZeroFileSize);
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub const fn value(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FileSize {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A half-open-by-one byte range `[start, end_inclusive]`, as HTTP uses
|
||||
/// inclusive end positions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ByteRange {
|
||||
start: u64,
|
||||
end_inclusive: u64,
|
||||
}
|
||||
|
||||
impl ByteRange {
|
||||
pub fn try_new(start: u64, end_inclusive: u64) -> Result<Self, FetchError> {
|
||||
if end_inclusive < start {
|
||||
return Err(FetchError::InvalidPlan(format!(
|
||||
"range start {start} is after end {end_inclusive}"
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
start,
|
||||
end_inclusive,
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn start(self) -> u64 {
|
||||
self.start
|
||||
}
|
||||
|
||||
pub const fn end_inclusive(self) -> u64 {
|
||||
self.end_inclusive
|
||||
}
|
||||
|
||||
pub const fn len(self) -> u64 {
|
||||
self.end_inclusive - self.start + 1
|
||||
}
|
||||
|
||||
pub const fn is_empty(self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Value for the HTTP `Range` header, e.g. `bytes=100-199`.
|
||||
pub fn http_value(self) -> String {
|
||||
format!("bytes={}-{}", self.start, self.end_inclusive)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ByteRange {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}..={} ({} bytes)",
|
||||
self.start,
|
||||
self.end_inclusive,
|
||||
self.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Position of a segment inside a plan, sequential from 0.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SegmentIndex(u32);
|
||||
|
||||
impl SegmentIndex {
|
||||
pub const fn new(value: u32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub const fn value(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SegmentIndex {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A validated SHA-256 digest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Sha256Digest([u8; 32]);
|
||||
|
||||
impl Sha256Digest {
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub fn from_hex(hex_str: &str) -> Result<Self, FetchError> {
|
||||
let bytes = hex::decode(hex_str.trim()).map_err(|_| FetchError::InvalidDigest)?;
|
||||
let arr: [u8; 32] = bytes.try_into().map_err(|_| FetchError::InvalidDigest)?;
|
||||
Ok(Self(arr))
|
||||
}
|
||||
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Sha256Digest {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
/// A validated target URL. Only http(s) is accepted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TargetUrl(reqwest::Url);
|
||||
|
||||
impl TargetUrl {
|
||||
pub fn parse(input: &str) -> Result<Self, FetchError> {
|
||||
let url =
|
||||
reqwest::Url::parse(input).map_err(|_| FetchError::InvalidUrl(input.to_string()))?;
|
||||
match url.scheme() {
|
||||
"http" | "https" => Ok(Self(url)),
|
||||
_ => Err(FetchError::InvalidUrl(input.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inner(&self) -> &reqwest::Url {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TargetUrl {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of probing a server's range-request support.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RangeSupport {
|
||||
/// Server advertised `Accept-Ranges: bytes` or answered a range probe
|
||||
/// with `206 Partial Content`.
|
||||
Supported,
|
||||
/// Server advertised `Accept-Ranges: none` or answered a range probe
|
||||
/// with a full `200` response.
|
||||
Unsupported,
|
||||
/// Probe was inconclusive; treat as unsupported (safe fallback).
|
||||
Unknown,
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! A minimal HTTP server for integration tests. Supports HEAD, GET with
|
||||
//! optional range handling (configurable), `Connection: close` semantics.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
pub struct TestServer {
|
||||
pub addr: SocketAddr,
|
||||
_handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
/// Start serving `data` on a random port. When `ranges` is false the
|
||||
/// server behaves like a range-hostile origin: no `Accept-Ranges`
|
||||
/// header, and `Range` requests are answered with the full body.
|
||||
pub fn start(data: Vec<u8>, ranges: bool) -> Self {
|
||||
let data = Arc::new(data);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind in test");
|
||||
let addr = listener.local_addr().expect("addr in test");
|
||||
let handle = std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let data = Arc::clone(&data);
|
||||
std::thread::spawn(move || {
|
||||
let _ = handle_conn(stream, &data, ranges);
|
||||
});
|
||||
}
|
||||
});
|
||||
Self {
|
||||
addr,
|
||||
_handle: handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self, path: &str) -> String {
|
||||
format!("http://{}{path}", self.addr)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_conn(stream: TcpStream, data: &[u8], ranges: bool) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut request_line = String::new();
|
||||
reader.read_line(&mut request_line)?;
|
||||
|
||||
let mut range_header: Option<String> = None;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
break;
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if let Some(value) = lower.strip_prefix("range:") {
|
||||
range_header = Some(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream = stream;
|
||||
let len = data.len() as u64;
|
||||
|
||||
let method = request_line
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("GET")
|
||||
.to_string();
|
||||
|
||||
match method.as_str() {
|
||||
"HEAD" => {
|
||||
let accept = if ranges { "bytes" } else { "none" };
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nAccept-Ranges: {accept}\r\nConnection: close\r\n\r\n"
|
||||
)?;
|
||||
}
|
||||
"GET" => {
|
||||
let range = range_header.as_deref().and_then(parse_range).flatten();
|
||||
match (ranges, range) {
|
||||
(true, Some((start, end))) => {
|
||||
let slice_len = end - start + 1;
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 206 Partial Content\r\nContent-Length: {slice_len}\r\nContent-Range: bytes {start}-{end}/{len}\r\nConnection: close\r\n\r\n"
|
||||
)?;
|
||||
stream.write_all(&data[start as usize..=(end as usize).min(data.len() - 1)])?;
|
||||
}
|
||||
_ => {
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n"
|
||||
)?;
|
||||
stream.write_all(data)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
stream.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `bytes=a-b` into inclusive `(start, end)`. Anything else
|
||||
/// (open-ended, malformed) is treated as absent.
|
||||
fn parse_range(value: &str) -> Option<Option<(u64, u64)>> {
|
||||
let spec = value.strip_prefix("bytes=")?;
|
||||
let (start_str, end_str) = spec.split_once('-')?;
|
||||
let start: u64 = start_str.trim().parse().ok()?;
|
||||
let end: u64 = end_str.trim().parse().ok()?;
|
||||
if end >= start {
|
||||
Some(Some((start, end)))
|
||||
} else {
|
||||
Some(None)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Integration tests: probe, planning, parallel segmented download,
|
||||
//! reassembly and hash verification against a local HTTP server — plus
|
||||
//! the fallback path for range-hostile servers.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::num::{NonZeroU32, NonZeroU64, NonZeroU8, NonZeroUsize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use sn_fetch::error::FetchError;
|
||||
use sn_fetch::exec;
|
||||
use sn_fetch::exit::LocalExit;
|
||||
use sn_fetch::plan::{ExitSpecs, FetchPlan};
|
||||
use sn_fetch::probe;
|
||||
use sn_fetch::types::{
|
||||
ByteRange, ExitId, ExitWeight, FileSize, MinSegmentBytes, RangeSupport, SegmentsPerExit,
|
||||
Sha256Digest,
|
||||
};
|
||||
|
||||
fn exit_specs(n: u8) -> ExitSpecs {
|
||||
(1..=n)
|
||||
.map(|i| {
|
||||
(
|
||||
ExitId::new(NonZeroU8::new(i).expect("test ids are 1..=255")),
|
||||
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_detects_range_support() {
|
||||
let data = vec![42u8; 1024];
|
||||
let server = common::TestServer::start(data, true);
|
||||
let url = sn_fetch::types::TargetUrl::parse(&server.url("/file.bin")).expect("valid url");
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let info = probe::probe(&client, &url).await.expect("probe succeeds");
|
||||
assert_eq!(info.range_support, RangeSupport::Supported);
|
||||
assert_eq!(info.content_length.map(|s| s.value()), Some(1024));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn segmented_download_reassembles_and_hashes() {
|
||||
let data: Vec<u8> = (0..(2 * 1024 * 1024)).map(|i| (i % 251) as u8).collect();
|
||||
let expected_digest = Sha256Digest::from_bytes(Sha256::digest(&data).into());
|
||||
|
||||
let server = common::TestServer::start(data.clone(), true);
|
||||
let url = sn_fetch::types::TargetUrl::parse(&server.url("/file.bin")).expect("valid url");
|
||||
|
||||
let specs = exit_specs(2);
|
||||
let plan = FetchPlan::build(
|
||||
url.clone(),
|
||||
FileSize::try_new(data.len() as u64).expect("non-zero in test"),
|
||||
&specs,
|
||||
SegmentsPerExit::new(NonZeroUsize::new(2).expect("2 is non-zero")),
|
||||
MinSegmentBytes::new(NonZeroU64::new(1024).expect("1024 is non-zero")),
|
||||
)
|
||||
.expect("plan builds");
|
||||
plan.validate().expect("plan validates");
|
||||
|
||||
let mut registry = sn_fetch::exit::ExitRegistry::empty();
|
||||
for id in specs.iter().map(|(id, _)| *id) {
|
||||
registry.register(Arc::new(LocalExit::new(id).expect("local exit")));
|
||||
}
|
||||
|
||||
let parts = TempDir::new().expect("tempdir in test");
|
||||
let stats = exec::run_segments(&plan, ®istry, parts.path())
|
||||
.await
|
||||
.expect("segments succeed");
|
||||
assert_eq!(stats.len(), 4);
|
||||
let total_downloaded: u64 = stats.iter().map(|s| s.bytes).sum();
|
||||
assert_eq!(total_downloaded, data.len() as u64);
|
||||
|
||||
let output = parts.path().join("assembled.bin");
|
||||
let digest = exec::assemble(
|
||||
parts.path(),
|
||||
&output,
|
||||
FileSize::try_new(data.len() as u64).expect("non-zero in test"),
|
||||
Some(&expected_digest),
|
||||
)
|
||||
.await
|
||||
.expect("assembly verifies");
|
||||
|
||||
assert_eq!(digest, expected_digest);
|
||||
let written = std::fs::read(&output).expect("output readable in test");
|
||||
assert_eq!(written, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn range_hostile_server_falls_back_to_single_stream() {
|
||||
let data: Vec<u8> = (0..256_000u32).map(|i| (i % 197) as u8).collect();
|
||||
let server = common::TestServer::start(data.clone(), false);
|
||||
let url_str = server.url("/file.bin");
|
||||
let target = sn_fetch::types::TargetUrl::parse(&url_str).expect("valid url");
|
||||
|
||||
let specs = exit_specs(1);
|
||||
let options = sn_fetch::runner::FetchOptions {
|
||||
output: std::env::temp_dir().join("sn-fetch-test-fallback.bin"),
|
||||
exits: specs.clone(),
|
||||
segments_per_exit: SegmentsPerExit::new(NonZeroUsize::new(1).expect("1 is non-zero")),
|
||||
min_segment: MinSegmentBytes::new(NonZeroU64::new(1024).expect("1024 is non-zero")),
|
||||
expected_sha256: None,
|
||||
};
|
||||
let registry = sn_fetch::runner::local_registry(&specs).expect("registry");
|
||||
let parts = TempDir::new().expect("tempdir in test");
|
||||
|
||||
let report = sn_fetch::runner::run(&target, &options, ®istry, parts.path())
|
||||
.await
|
||||
.expect("fallback download succeeds");
|
||||
|
||||
assert_eq!(report.file_size.value(), 256_000);
|
||||
let written = std::fs::read(&options.output).expect("output readable in test");
|
||||
assert_eq!(written, data);
|
||||
let _ = std::fs::remove_file(&options.output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_is_contiguous_and_weighted() {
|
||||
let url = sn_fetch::types::TargetUrl::parse("http://example.com/file.bin").expect("valid url");
|
||||
let size = FileSize::try_new(10_000).expect("non-zero in test");
|
||||
let specs: ExitSpecs = vec![
|
||||
(
|
||||
ExitId::new(NonZeroU8::new(1).expect("1 is non-zero")),
|
||||
ExitWeight::new(NonZeroU32::new(3).expect("3 is non-zero")),
|
||||
),
|
||||
(
|
||||
ExitId::new(NonZeroU8::new(2).expect("2 is non-zero")),
|
||||
ExitWeight::new(NonZeroU32::new(1).expect("1 is non-zero")),
|
||||
),
|
||||
];
|
||||
|
||||
let plan = FetchPlan::build(
|
||||
url,
|
||||
size,
|
||||
&specs,
|
||||
SegmentsPerExit::new(NonZeroUsize::new(1).expect("1 is non-zero")),
|
||||
MinSegmentBytes::new(NonZeroU64::new(100).expect("100 is non-zero")),
|
||||
)
|
||||
.expect("plan builds");
|
||||
|
||||
plan.validate().expect("plan validates");
|
||||
|
||||
let exit1_total: u64 = plan
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|seg| seg.exit.value() == 1)
|
||||
.map(|seg| seg.range.len())
|
||||
.sum();
|
||||
let exit2_total: u64 = plan
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|seg| seg.exit.value() == 2)
|
||||
.map(|seg| seg.range.len())
|
||||
.sum();
|
||||
assert_eq!(exit1_total + exit2_total, 10_000);
|
||||
assert!(
|
||||
exit1_total > exit2_total,
|
||||
"heavier exit must get the larger share"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_range_http_value() {
|
||||
let range = ByteRange::try_new(100, 199).expect("valid range");
|
||||
assert_eq!(range.len(), 100);
|
||||
assert_eq!(range.http_value(), "bytes=100-199");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_url_is_rejected() {
|
||||
assert!(matches!(
|
||||
sn_fetch::types::TargetUrl::parse("ftp://example.com/file"),
|
||||
Err(FetchError::InvalidUrl(_))
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user