use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use bee::manifest::{MantarayNode, unmarshal};
use bee::swarm::Reference;
use bee::swarm::bmt::calculate_chunk_address;
use crate::api::ApiClient;
const MAX_CHUNKS_PER_WALK: u64 = 10_000;
#[derive(Debug, Clone)]
pub struct DurabilityResult {
pub reference: Reference,
pub started_at: SystemTime,
pub duration_ms: u64,
pub chunks_total: u64,
pub chunks_lost: u64,
pub chunks_errors: u64,
pub chunks_corrupt: u64,
pub root_is_manifest: bool,
pub truncated: bool,
pub bmt_verified: bool,
pub swarmscan_seen: Option<bool>,
}
impl DurabilityResult {
pub fn is_healthy(&self) -> bool {
self.chunks_lost == 0 && self.chunks_errors == 0 && self.chunks_corrupt == 0
}
pub fn summary(&self) -> String {
let kind = if self.root_is_manifest {
"manifest"
} else {
"raw chunk"
};
let trunc = if self.truncated { " (truncated)" } else { "" };
let verify = if self.bmt_verified { " · BMT" } else { "" };
let swarmscan = match self.swarmscan_seen {
Some(true) => " · swarmscan: seen",
Some(false) => " · swarmscan: NOT seen",
None => "",
};
if self.is_healthy() {
format!(
"durability-check OK in {}ms · {kind} · {} chunk{} retrievable{verify}{swarmscan}{trunc}",
self.duration_ms,
self.chunks_total,
if self.chunks_total == 1 { "" } else { "s" },
)
} else {
format!(
"durability-check UNHEALTHY in {}ms · {kind} · total {} · lost {} · errors {} · corrupt {}{swarmscan}{trunc}",
self.duration_ms,
self.chunks_total,
self.chunks_lost,
self.chunks_errors,
self.chunks_corrupt,
)
}
}
}
pub async fn check(api: Arc<ApiClient>, reference: Reference) -> DurabilityResult {
check_with_options(api, reference, CheckOptions::default()).await
}
#[derive(Debug, Clone)]
pub struct CheckOptions {
pub bmt_verify: bool,
pub swarmscan_url: Option<String>,
}
impl Default for CheckOptions {
fn default() -> Self {
Self {
bmt_verify: true,
swarmscan_url: None,
}
}
}
pub async fn check_with_options(
api: Arc<ApiClient>,
reference: Reference,
opts: CheckOptions,
) -> DurabilityResult {
let started = Instant::now();
let started_at = SystemTime::now();
let mut result = DurabilityResult {
reference: reference.clone(),
started_at,
duration_ms: 0,
chunks_total: 0,
chunks_lost: 0,
chunks_errors: 0,
chunks_corrupt: 0,
root_is_manifest: false,
truncated: false,
bmt_verified: opts.bmt_verify,
swarmscan_seen: None,
};
let root_bytes = match api.bee().file().download_chunk(&reference, None).await {
Ok(b) => b,
Err(e) => {
let s = e.to_string();
if s.contains("404") {
result.chunks_lost = 1;
} else {
result.chunks_errors = 1;
}
result.chunks_total = 1;
result.duration_ms = elapsed_ms(started);
return result;
}
};
result.chunks_total = 1;
if opts.bmt_verify && !bmt_matches(&root_bytes, reference.as_bytes()) {
result.chunks_corrupt += 1;
}
let root_node = match unmarshal(&root_bytes, reference.as_bytes()) {
Ok(n) => n,
Err(_) => {
result.duration_ms = elapsed_ms(started);
return result;
}
};
result.root_is_manifest = true;
let mut visited: HashSet<[u8; 32]> = HashSet::new();
let mut queue: Vec<MantarayNode> = vec![root_node];
while let Some(node) = queue.pop() {
for fork in node.forks.values() {
let Some(addr) = fork.node.self_address else {
continue;
};
if !visited.insert(addr) {
continue;
}
if result.chunks_total >= MAX_CHUNKS_PER_WALK {
result.truncated = true;
result.duration_ms = elapsed_ms(started);
return result;
}
result.chunks_total += 1;
let child_ref = match Reference::new(&addr) {
Ok(r) => r,
Err(_) => {
result.chunks_errors += 1;
continue;
}
};
match api.bee().file().download_chunk(&child_ref, None).await {
Ok(child_bytes) => {
if opts.bmt_verify && !bmt_matches(&child_bytes, child_ref.as_bytes()) {
result.chunks_corrupt += 1;
continue;
}
if let Ok(child_node) = unmarshal(&child_bytes, child_ref.as_bytes()) {
queue.push(child_node);
}
}
Err(e) => {
if e.to_string().contains("404") {
result.chunks_lost += 1;
} else {
result.chunks_errors += 1;
}
}
}
}
}
if let Some(template) = opts.swarmscan_url.as_deref() {
result.swarmscan_seen = swarmscan_probe(template, &reference).await;
}
result.duration_ms = elapsed_ms(started);
result
}
async fn swarmscan_probe(url_template: &str, reference: &Reference) -> Option<bool> {
let url = url_template.replace("{ref}", &reference.to_hex());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.user_agent(concat!("bee-tui/", env!("CARGO_PKG_VERSION")))
.build()
.ok()?;
match client.get(&url).send().await {
Ok(resp) => match resp.status().as_u16() {
200 => Some(true),
404 => Some(false),
_ => None,
},
Err(_) => None,
}
}
fn bmt_matches(bytes: &[u8], expected: &[u8]) -> bool {
match calculate_chunk_address(bytes) {
Ok(a) => a.as_slice() == expected,
Err(_) => false,
}
}
fn elapsed_ms(started: Instant) -> u64 {
let d: Duration = started.elapsed();
d.as_millis().min(u128::from(u64::MAX)) as u64
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_ref() -> Reference {
Reference::from_hex(&"a".repeat(64)).unwrap()
}
#[test]
fn summary_renders_healthy_message() {
let r = DurabilityResult {
reference: fake_ref(),
started_at: SystemTime::now(),
duration_ms: 123,
chunks_total: 4,
chunks_lost: 0,
chunks_errors: 0,
chunks_corrupt: 0,
root_is_manifest: true,
truncated: false,
bmt_verified: true,
swarmscan_seen: None,
};
let s = r.summary();
assert!(s.contains("OK"), "{s}");
assert!(s.contains("4 chunks retrievable"), "{s}");
assert!(s.contains("manifest"), "{s}");
}
#[test]
fn summary_renders_unhealthy_breakdown() {
let r = DurabilityResult {
reference: fake_ref(),
started_at: SystemTime::now(),
duration_ms: 990,
chunks_total: 8,
chunks_lost: 2,
chunks_errors: 1,
chunks_corrupt: 0,
root_is_manifest: true,
truncated: false,
bmt_verified: true,
swarmscan_seen: None,
};
let s = r.summary();
assert!(s.contains("UNHEALTHY"), "{s}");
assert!(s.contains("lost 2"), "{s}");
assert!(s.contains("errors 1"), "{s}");
}
#[test]
fn summary_includes_corrupt_when_bmt_finds_mismatch() {
let r = DurabilityResult {
reference: fake_ref(),
started_at: SystemTime::now(),
duration_ms: 100,
chunks_total: 5,
chunks_lost: 0,
chunks_errors: 0,
chunks_corrupt: 2,
root_is_manifest: true,
truncated: false,
bmt_verified: true,
swarmscan_seen: None,
};
let s = r.summary();
assert!(!r.is_healthy());
assert!(s.contains("UNHEALTHY"), "{s}");
assert!(s.contains("corrupt 2"), "{s}");
}
#[test]
fn summary_includes_bmt_marker_when_verified() {
let r = DurabilityResult {
reference: fake_ref(),
started_at: SystemTime::now(),
duration_ms: 100,
chunks_total: 3,
chunks_lost: 0,
chunks_errors: 0,
chunks_corrupt: 0,
root_is_manifest: true,
truncated: false,
bmt_verified: true,
swarmscan_seen: None,
};
assert!(r.summary().contains("BMT"), "{}", r.summary());
}
#[test]
fn summary_omits_bmt_marker_when_skipped() {
let r = DurabilityResult {
reference: fake_ref(),
started_at: SystemTime::now(),
duration_ms: 100,
chunks_total: 3,
chunks_lost: 0,
chunks_errors: 0,
chunks_corrupt: 0,
root_is_manifest: true,
truncated: false,
bmt_verified: false,
swarmscan_seen: None,
};
assert!(!r.summary().contains("BMT"), "{}", r.summary());
}
#[test]
fn truncated_flag_surfaces_in_summary() {
let r = DurabilityResult {
reference: fake_ref(),
started_at: SystemTime::now(),
duration_ms: 1,
chunks_total: 10_000,
chunks_lost: 0,
chunks_errors: 0,
chunks_corrupt: 0,
root_is_manifest: true,
truncated: true,
bmt_verified: true,
swarmscan_seen: None,
};
assert!(r.summary().contains("truncated"), "{}", r.summary());
}
#[test]
fn is_healthy_requires_zero_lost_errors_and_corrupt() {
let mut r = DurabilityResult {
reference: fake_ref(),
started_at: SystemTime::now(),
duration_ms: 1,
chunks_total: 5,
chunks_lost: 0,
chunks_errors: 0,
chunks_corrupt: 0,
root_is_manifest: true,
truncated: false,
bmt_verified: true,
swarmscan_seen: None,
};
assert!(r.is_healthy());
r.chunks_lost = 1;
assert!(!r.is_healthy());
r.chunks_lost = 0;
r.chunks_errors = 1;
assert!(!r.is_healthy());
r.chunks_errors = 0;
r.chunks_corrupt = 1;
assert!(!r.is_healthy());
}
#[test]
fn summary_includes_swarmscan_seen() {
let mut r = DurabilityResult {
reference: fake_ref(),
started_at: SystemTime::now(),
duration_ms: 100,
chunks_total: 3,
chunks_lost: 0,
chunks_errors: 0,
chunks_corrupt: 0,
root_is_manifest: true,
truncated: false,
bmt_verified: true,
swarmscan_seen: Some(true),
};
assert!(r.summary().contains("swarmscan: seen"), "{}", r.summary());
r.swarmscan_seen = Some(false);
assert!(
r.summary().contains("swarmscan: NOT seen"),
"{}",
r.summary(),
);
r.swarmscan_seen = None;
assert!(!r.summary().contains("swarmscan"), "{}", r.summary());
}
#[test]
fn bmt_matches_verifies_real_chunk() {
use bee::swarm::bmt::calculate_chunk_address;
let payload = b"some chunk content".to_vec();
let span_len = (payload.len() as u64).to_le_bytes();
let mut bytes = Vec::with_capacity(8 + payload.len());
bytes.extend_from_slice(&span_len);
bytes.extend_from_slice(&payload);
let addr = calculate_chunk_address(&bytes).expect("hash ok");
assert!(bmt_matches(&bytes, addr.as_slice()));
let mut tampered = bytes.clone();
tampered[10] ^= 0xff;
assert!(!bmt_matches(&tampered, addr.as_slice()));
}
}