sn-fetch PoC: range probe, weighted planner, parallel segments, reassembly + sha256

This commit is contained in:
2026-09-24 13:53:35 +02:00
parent 3cb2890425
commit 9a61452931
18 changed files with 3290 additions and 6 deletions
+3
View File
@@ -20,3 +20,6 @@ documentation/.obsidian/
*.log *.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Rust build artifacts (workspace root is server/)
server/target/
+5 -2
View File
@@ -15,10 +15,13 @@ endet mit etwas, das man benutzen kann.
Ziel: Beweis, dass Pooling über zwei Anschlüsse real funktioniert. Ziel: Beweis, dass Pooling über zwei Anschlüsse real funktioniert.
- [x] `sn-fetch` PoC (lokal): Range-Probe, gewichteter Planner, parallele
Segment-Downloads, Reassembly + SHA-256 — verifiziert gegen einen
echten Server (Debian-Mirror); Multi-Exit-Routing folgt mit dem Mesh
- [ ] SpiderNet-Node auf Mini-PC (Raspberry Pi / N100-Box), Yggdrasil-Overlay - [ ] SpiderNet-Node auf Mini-PC (Raspberry Pi / N100-Box), Yggdrasil-Overlay
zwischen 2 Haushalten (Kabel oder Kurzstrecke WLAN/60-GHz) zwischen 2 Haushalten (Kabel oder Kurzstrecke WLAN/60-GHz)
- [ ] `sn-fetch` PoC: segmentierter Download (HTTP-Range-Splitting) über - [ ] `sn-fetch` PoC über 2 echte Exits, plus Baseline-Messung
2 Exits, plus Baseline-Messung (1-Exit vs. 2-Exit, gleiche Datei) (1-Exit vs. 2-Exit, gleiche Datei)
- [ ] Fair-Share-Scheduler v0: zwei gleichzeitige Downloads, faire Aufteilung - [ ] Fair-Share-Scheduler v0: zwei gleichzeitige Downloads, faire Aufteilung
- [ ] Doku: Messwerte, Erkenntnisse, Stolpersteine → hier in das Repo - [ ] Doku: Messwerte, Erkenntnisse, Stolpersteine → hier in das Repo
+4 -2
View File
@@ -55,8 +55,10 @@ die Verbindung kaputt machen würde (IP-Wechsel beendet die Session):
| VPNs (WireGuard etc.) | UDP-Flow mit State | | VPNs (WireGuard etc.) | UDP-Flow mit State |
**Policy-Vorschlag (Default):** realzeitkritischer Traffic geht in den **Policy-Vorschlag (Default):** realzeitkritischer Traffic geht in den
**Direct-Modus** — ohne Umweg, normal über den eigenen Router/Uplink, als **Direct-Modus** — und zwar als echter **Pass-Through**: SpiderNet gibt
wäre SpiderNet nicht da. Optional "best single exit" (wenn die eigene diese Verbindungen unverändert weiter, sie nehmen den normalen Weg durch
den eigenen Router und den eigenen Uplink, als wäre SpiderNet nicht da
(kein Tunnel, kein MITM). Optional "best single exit" (wenn die eigene
Leitung gestört/überlastet ist, kann man den Call bewusst über einen Leitung gestört/überlastet ist, kann man den Call bewusst über einen
Nachbarschafts-Exit pinnen — aber dann sticky für die Dauer der Session). Nachbarschafts-Exit pinnen — aber dann sticky für die Dauer der Session).
+1637
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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
View File
@@ -1,7 +1,28 @@
# SpiderNet-Node — Server/Software (Konzeptphase) # SpiderNet-Node — Server/Software
Die Software, die aus "Nachbarschafts-LAN" ein gepooltes Bandbreitennetz 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) ## Haftungsausschluss (muss im Produkt sichtbar sein)
+17
View File
@@ -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 }
+50
View File
@@ -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,
}
+204
View File
@@ -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()))
}
+73
View File
@@ -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()
}
}
+18
View File
@@ -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;
+182
View File
@@ -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, &registry, &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(())
}
+279
View File
@@ -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
}
+66
View File
@@ -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()),
})
}
+171
View File
@@ -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)
}
+232
View File
@@ -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,
}
+123
View File
@@ -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)
}
}
+181
View File
@@ -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, &registry, 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, &registry, 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(_))
));
}