Deny unwrap/expect/panic via workspace lints; fix pedantic warnings

This commit is contained in:
2026-09-24 14:04:03 +02:00
parent 9a61452931
commit f4e614acce
12 changed files with 93 additions and 60 deletions
+10
View File
@@ -7,6 +7,16 @@ version = "0.1.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
[workspace.lints.rust]
unsafe_code = "deny" # also #![forbid(unsafe_code)] in every crate root
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
unreachable = "deny"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
+13
View File
@@ -86,6 +86,19 @@ Kleinster brauchbarer Schnitt:
Der Code soll so sicher sein, dass ganze Fehlerklassen schon beim Kompilieren Der Code soll so sicher sein, dass ganze Fehlerklassen schon beim Kompilieren
unmöglich werden. Verbindliche Regeln für jede Crate in `server/`: unmöglich werden. Verbindliche Regeln für jede Crate in `server/`:
**Maschinell erzwungen** (Workspace-Lints in `server/Cargo.toml`, in jeder
Crate via `[lints] workspace = true` aktiviert):
- `unsafe_code = "deny"` + `#![forbid(unsafe_code)]` im Crate-Root
- `clippy::unwrap_used = "deny"` — kein `unwrap()` (Panic-Quelle, kein Test)
- `clippy::expect_used = "deny"` — kein `expect()` (Panic-Quelle, kein Test)
- `clippy::panic = "deny"`, `clippy::unreachable = "deny"`
- `clippy::all = "deny"`, `clippy::pedantic = "warn"` (Deny-Ziel vor Release)
Verifiziert: ein eingebautes `unwrap()` in `lib.rs` wurde von Clippy mit
`error: used unwrap() ... #unwrap_used` abgelehnt. Tests dürfen
`unwrap()`/`expect()` (explizites `#![allow(...)]` pro Test-Crate).
1. **`#![forbid(unsafe_code)]`** in jedem Crate-Root (und `unwrap`, 1. **`#![forbid(unsafe_code)]`** in jedem Crate-Root (und `unwrap`,
`expect` nur in `#[cfg(test)]`-Kontext). `expect` nur in `#[cfg(test)]`-Kontext).
2. **Newtypes statt roher Primitive** für alles, was Bedeutung hat: 2. **Newtypes statt roher Primitive** für alles, was Bedeutung hat:
+3
View File
@@ -15,3 +15,6 @@ tempfile = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
[lints]
workspace = true
+1 -1
View File
@@ -145,7 +145,7 @@ pub async fn assemble(
for part_path in &part_paths { for part_path in &part_paths {
let mut part = tokio::fs::File::open(part_path).await?; let mut part = tokio::fs::File::open(part_path).await?;
let mut buffer = [0u8; 64 * 1024]; let mut buffer = vec![0u8; 64 * 1024];
loop { loop {
let n = tokio::io::AsyncReadExt::read(&mut part, &mut buffer).await?; let n = tokio::io::AsyncReadExt::read(&mut part, &mut buffer).await?;
if n == 0 { if n == 0 {
+1 -1
View File
@@ -1,7 +1,7 @@
//! Exit abstraction: an "exit" is one internet uplink through which //! Exit abstraction: an "exit" is one internet uplink through which
//! segment requests can be sent. //! segment requests can be sent.
//! //!
//! PoC v0 ships only [`LocalExit`] (the household's own connection, no //! `PoC` v0 ships only [`LocalExit`] (the household's own connection, no
//! detour). The registry/trait structure is deliberately ready so a //! detour). The registry/trait structure is deliberately ready so a
//! future exit implementation can route HTTP through a neighbour over //! future exit implementation can route HTTP through a neighbour over
//! the mesh (e.g. via a per-exit proxy or tunnel) without changing the //! the mesh (e.g. via a per-exit proxy or tunnel) without changing the
+7 -3
View File
@@ -1,13 +1,17 @@
//! sn-fetch — segmented multi-exit downloader for SpiderNet. //! sn-fetch — segmented multi-exit downloader for `SpiderNet`.
//! //!
//! PoC v0: probes a URL for HTTP range support, plans weighted byte-range //! `PoC` v0: probes a URL for HTTP range support, plans weighted byte-range
//! segments across exits, downloads segments in parallel and reassembles //! segments across exits, downloads segments in parallel and reassembles
//! the target file with SHA-256 verification. //! the target file with SHA-256 verification.
//! //!
//! SpiderNet software carries no warranty and accepts no responsibility //! `SpiderNet` software carries no warranty and accepts no responsibility
//! for its usage or for transported content (AGPL-3.0, sections 15/16). //! for its usage or for transported content (AGPL-3.0, sections 15/16).
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![warn(clippy::pedantic)]
// deny is the goal before the first release
// PoC scope: doc-annotation noise, not correctness. Revisit at release.
#![allow(clippy::must_use_candidate, clippy::missing_errors_doc)]
pub mod error; pub mod error;
pub mod exec; pub mod exec;
+15 -9
View File
@@ -1,10 +1,13 @@
//! CLI entry point for the sn-fetch PoC. //! CLI entry point for the sn-fetch `PoC`.
//! //!
//! KEINE GARANTIE — this software is provided "as is" without warranty of //! KEINE GARANTIE — this software is provided "as is" without warranty of
//! any kind (AGPL-3.0, sections 15/16). Users are solely responsible for //! any kind (AGPL-3.0, sections 15/16). Users are solely responsible for
//! what they transmit or receive with it. //! what they transmit or receive with it.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![warn(clippy::pedantic)]
// PoC scope: doc-annotation noise, not correctness. Revisit at release.
#![allow(clippy::must_use_candidate, clippy::missing_errors_doc)]
use std::num::{NonZeroU32, NonZeroU64, NonZeroU8, NonZeroUsize}; use std::num::{NonZeroU32, NonZeroU64, NonZeroU8, NonZeroUsize};
use std::path::PathBuf; use std::path::PathBuf;
@@ -26,7 +29,7 @@ struct Args {
output: Option<PathBuf>, output: Option<PathBuf>,
/// Exits as `id=weight` pairs, comma separated, e.g. `local=1,local2=2`. /// 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 /// `PoC`: all ids map to plain local connections; the weights only shape
/// the plan. Multi-exit routing comes with the mesh integration. /// the plan. Multi-exit routing comes with the mesh integration.
#[arg(short = 'e', long = "exit", default_value = "local=1")] #[arg(short = 'e', long = "exit", default_value = "local=1")]
exits: String, exits: String,
@@ -79,8 +82,7 @@ fn default_output(url: &TargetUrl) -> PathBuf {
let name = segments let name = segments
.next_back() .next_back()
.filter(|name| !name.is_empty()) .filter(|name| !name.is_empty())
.map(str::to_string) .map_or_else(|| "download.bin".to_string(), str::to_string);
.unwrap_or_else(|| "download.bin".to_string());
PathBuf::from(name) PathBuf::from(name)
} }
@@ -165,14 +167,12 @@ async fn run() -> Result<(), String> {
println!( println!(
"time: {:.2?} s → effective {:.1} MiB/s", "time: {:.2?} s → effective {:.1} MiB/s",
report.wall_time.as_secs_f64(), report.wall_time.as_secs_f64(),
report.file_size.value() as f64 / 1024.0 / 1024.0 / report.wall_time.as_secs_f64() bytes_to_mib(report.file_size.value()) / report.wall_time.as_secs_f64()
); );
println!("segments:"); println!("segments:");
for segment in &report.segments { for segment in &report.segments {
let mib_per_s = segment.bytes as f64 let mib_per_s =
/ 1024.0 bytes_to_mib(segment.bytes) / segment.duration.as_secs_f64().max(f64::EPSILON);
/ 1024.0
/ segment.duration.as_secs_f64().max(f64::EPSILON);
println!( println!(
" #{}: {} bytes in {:.2?} s ({mib_per_s:.1} MiB/s)", " #{}: {} bytes in {:.2?} s ({mib_per_s:.1} MiB/s)",
segment.index, segment.bytes, segment.duration segment.index, segment.bytes, segment.duration
@@ -180,3 +180,9 @@ async fn run() -> Result<(), String> {
} }
Ok(()) Ok(())
} }
/// Bytes to MiB (display-only; precision loss is irrelevant here).
#[allow(clippy::cast_precision_loss)]
fn bytes_to_mib(bytes: u64) -> f64 {
bytes as f64 / (1024.0 * 1024.0)
}
+21 -31
View File
@@ -57,8 +57,7 @@ impl FetchPlan {
.iter() .iter()
.enumerate() .enumerate()
.min_by_key(|(_, (id, weight))| (weight.value(), std::cmp::Reverse(id.value()))) .min_by_key(|(_, (id, weight))| (weight.value(), std::cmp::Reverse(id.value())))
.map(|(pos, _)| pos) .map_or(0, |(pos, _)| pos);
.unwrap_or(0);
exits.remove(weakest); exits.remove(weakest);
} }
// Deterministic boundary assignment order. // Deterministic boundary assignment order.
@@ -120,15 +119,11 @@ impl FetchPlan {
// The last exit covers to the end, so nothing can be left // The last exit covers to the end, so nothing can be left
// over; stay honest and extend the last assignment anyway. // over; stay honest and extend the last assignment anyway.
if start < file_size.value() { if start < file_size.value() {
match assignments.last_mut() { if let Some((last_range, _)) = assignments.last_mut() {
Some((last_range, _)) => { *last_range = ByteRange::try_new(last_range.start(), file_size.value() - 1)?;
*last_range = } else {
ByteRange::try_new(last_range.start(), file_size.value() - 1)?; let only = ByteRange::try_new(0, file_size.value() - 1)?;
} assignments.push((only, exits[0].0));
None => {
let only = ByteRange::try_new(0, file_size.value() - 1)?;
assignments.push((only, exits[0].0));
}
} }
} }
} }
@@ -212,7 +207,7 @@ fn even_subsplit(start: u64, end_inclusive: u64, parts: usize) -> Vec<ByteRange>
let mut out = Vec::with_capacity(parts); let mut out = Vec::with_capacity(parts);
let mut cursor = start; let mut cursor = start;
for i in 0..parts { for i in 0..parts {
let len = base + if (i as u64) < remainder { 1 } else { 0 }; let len = base + u64::from((i as u64) < remainder);
let range_end = cursor + len - 1; let range_end = cursor + len - 1;
match ByteRange::try_new(cursor, range_end) { match ByteRange::try_new(cursor, range_end) {
Ok(range) => out.push(range), Ok(range) => out.push(range),
@@ -243,36 +238,31 @@ fn merge_tiny_segments(
let merged: (ByteRange, ExitId) = if pos + 1 < assignments.len() { let merged: (ByteRange, ExitId) = if pos + 1 < assignments.len() {
// Merge into the next segment, keeping its exit. // Merge into the next segment, keeping its exit.
let next = &assignments[pos + 1]; let next = &assignments[pos + 1];
let range = match ByteRange::try_new(assignments[pos].0.start(), next.0.end_inclusive()) let Ok(range) = ByteRange::try_new(assignments[pos].0.start(), next.0.end_inclusive())
{ else {
Ok(range) => range,
// Unreachable: next.start == pos.end + 1, so the merged // Unreachable: next.start == pos.end + 1, so the merged
// range is always well-formed. Drop both segments instead // range is always well-formed. Stop instead of panicking;
// of panicking; validate() would reject a broken plan. // validate() would reject a broken plan.
Err(_) => break, break;
}; };
(range, next.1) (range, next.1)
} else { } else {
// Last segment is too small: merge into the previous one. // Last segment is too small: merge into the previous one.
let prev_pos = pos - 1; let prev_pos = pos - 1;
let prev = &assignments[prev_pos]; let prev = &assignments[prev_pos];
let range = match ByteRange::try_new(prev.0.start(), assignments[pos].0.end_inclusive()) let Ok(range) = ByteRange::try_new(prev.0.start(), assignments[pos].0.end_inclusive())
{ else {
Ok(range) => range, break;
Err(_) => break,
}; };
(range, prev.1) (range, prev.1)
}; };
match pos + 1 < assignments.len() { if pos + 1 < assignments.len() {
true => { assignments[pos + 1] = merged;
assignments[pos + 1] = merged; assignments.remove(pos);
assignments.remove(pos); } else {
} assignments[pos - 1] = merged;
false => { assignments.remove(pos);
assignments[pos - 1] = merged;
assignments.remove(pos);
}
} }
} }
assignments assignments
+12 -13
View File
@@ -41,21 +41,20 @@ pub async fn probe(client: &reqwest::Client, url: &TargetUrl) -> Result<ProbeInf
_ => None, _ => None,
}; };
let range_support = match advertised { let range_support = if let Some(support) = advertised {
Some(support) => support, support
} else {
// HEAD was inconclusive: ask directly with a 1-byte range. // HEAD was inconclusive: ask directly with a 1-byte range.
None => { let resp = client
let resp = client .get(url.inner().clone())
.get(url.inner().clone()) .header(reqwest::header::RANGE, "bytes=0-0")
.header(reqwest::header::RANGE, "bytes=0-0") .send()
.send() .await?;
.await?;
match resp.status() { match resp.status() {
StatusCode::PARTIAL_CONTENT => RangeSupport::Supported, StatusCode::PARTIAL_CONTENT => RangeSupport::Supported,
StatusCode::OK => RangeSupport::Unsupported, StatusCode::OK => RangeSupport::Unsupported,
_ => RangeSupport::Unknown, _ => RangeSupport::Unknown,
}
} }
}; };
+4 -2
View File
@@ -41,13 +41,15 @@ pub async fn run(
let info = probe::probe(&probe_client, target).await?; let info = probe::probe(&probe_client, target).await?;
match info.range_support { match info.range_support {
RangeSupport::Supported => run_ranged(target, options, exits, parts_dir, info).await, RangeSupport::Supported => {
Box::pin(run_ranged(target, options, exits, parts_dir, info)).await
}
RangeSupport::Unsupported | RangeSupport::Unknown => { RangeSupport::Unsupported | RangeSupport::Unknown => {
tracing::warn!( tracing::warn!(
"server does not (confirmably) support range requests; \ "server does not (confirmably) support range requests; \
falling back to single-stream download without pooling" falling back to single-stream download without pooling"
); );
run_whole(target, options, exits, parts_dir, info).await Box::pin(run_whole(target, options, exits, parts_dir, info)).await
} }
} }
} }
@@ -2,6 +2,9 @@
//! optional range handling (configurable), `Connection: close` semantics. //! optional range handling (configurable), `Connection: close` semantics.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
// Tests are allowed to unwrap/expect; production code denies them via
// workspace lints (server/Cargo.toml).
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream}; use std::net::{SocketAddr, TcpListener, TcpStream};
@@ -3,6 +3,9 @@
//! the fallback path for range-hostile servers. //! the fallback path for range-hostile servers.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
// Tests are allowed to unwrap/expect; production code denies them via
// workspace lints (server/Cargo.toml).
#![allow(clippy::unwrap_used, clippy::expect_used)]
mod common; mod common;