use thiserror::Error;
pub const MAX_ERROR_REASON_CHARS: usize = 512;
pub const MAX_ERROR_CONTEXT_CHARS: usize = 4096;
pub fn hex64_or_sentinel(value: &str, label: &str) -> String {
let canonical = value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit());
if canonical {
value.to_ascii_lowercase()
} else {
format!("<non-canonical-{label}>")
}
}
pub fn sanitize_untrusted_text(text: &str, max_chars: usize) -> String {
let mut out = String::with_capacity(text.len().min(max_chars));
for (count, ch) in text.chars().enumerate() {
if count == max_chars {
out.push_str("…<truncated>");
break;
}
if ch.is_control() || is_bidi_control(ch) {
out.extend(ch.escape_debug());
} else {
out.push(ch);
}
}
out
}
fn is_bidi_control(ch: char) -> bool {
matches!(
ch,
'\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)
}
#[derive(Error)]
pub enum DownloadError {
#[error(
"transport error from provider {}: {}",
hex64_or_sentinel(provider, "peer-id"),
sanitize_untrusted_text(reason, MAX_ERROR_REASON_CHARS)
)]
Transport {
provider: String,
reason: String,
},
#[error(
"range fetch from provider {} timed out",
hex64_or_sentinel(provider, "peer-id")
)]
Timeout {
provider: String,
},
#[error(
"integrity failure: {}",
sanitize_untrusted_text(&.0.to_string(), MAX_ERROR_REASON_CHARS)
)]
Verify(#[from] VerifyError),
#[error("no providers left holding the content (needed {needed} more range(s))")]
NoProviders {
needed: usize,
},
#[error(
"content not found: {}",
sanitize_untrusted_text(content, MAX_ERROR_CONTEXT_CHARS)
)]
NotFound {
content: String,
},
#[error(
"no confirmed holder could seed the resource layout for {} — probed {holders}, all failed: {}",
sanitize_untrusted_text(content, MAX_ERROR_CONTEXT_CHARS),
sanitize_untrusted_text(&reasons.join("; "), MAX_ERROR_REASON_CHARS)
)]
MetadataProbeFailed {
content: String,
holders: usize,
reasons: Vec<String>,
},
#[error(
"provider {} served a {chunk_count}-entry chunk_lens paged prologue that ended incomplete \
({delivered} of {chunk_count} entries)",
hex64_or_sentinel(provider, "peer-id")
)]
PagedPrologueUnsupported {
provider: String,
chunk_count: u64,
delivered: u64,
},
#[error("download cancelled")]
Cancelled,
#[error("state store error: {0}")]
State(String),
#[error("sink write error: {0}")]
Sink(String),
#[error("content id is not directly downloadable (needs a root/capsule or resource, got a bare store id)")]
NotDownloadable,
#[error("download task ended without a result")]
TaskEnded,
}
impl std::fmt::Debug for DownloadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "DownloadError({self})")
}
}
impl DownloadError {
pub fn transport(provider: impl Into<String>, reason: impl std::fmt::Display) -> Self {
DownloadError::Transport {
provider: provider.into(),
reason: reason.to_string(),
}
}
pub fn sink(reason: impl std::fmt::Display) -> Self {
DownloadError::Sink(reason.to_string())
}
pub fn state(reason: impl std::fmt::Display) -> Self {
DownloadError::State(reason.to_string())
}
pub fn is_recoverable(&self) -> bool {
matches!(
self,
DownloadError::Transport { .. }
| DownloadError::Verify(_)
| DownloadError::Timeout { .. }
| DownloadError::PagedPrologueUnsupported { .. }
)
}
pub fn attributed_to(self, peer_id: &str) -> Self {
match self {
DownloadError::Transport { provider, reason } if provider.is_empty() => {
DownloadError::Transport {
provider: peer_id.to_string(),
reason,
}
}
DownloadError::PagedPrologueUnsupported {
provider,
chunk_count,
delivered,
} if provider.is_empty() => DownloadError::PagedPrologueUnsupported {
provider: peer_id.to_string(),
chunk_count,
delivered,
},
other => other,
}
}
}
#[derive(Error, Clone, PartialEq, Eq)]
pub enum VerifyError {
#[error("range length mismatch: expected {expected} bytes for chunks, got {actual}")]
Length {
expected: u64,
actual: u64,
},
#[error("range metadata mismatch with the resource commitment: {0}")]
Metadata(String),
#[error("range is not chunk-aligned: {0}")]
Alignment(String),
#[error("resource does not verify against the chain-anchored root")]
Root,
#[error("first frame is missing verification metadata ({0})")]
MissingMetadata(String),
}
impl std::fmt::Debug for VerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"VerifyError({})",
sanitize_untrusted_text(&self.to_string(), MAX_ERROR_REASON_CHARS)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_helper_formats_with_provider() {
let peer = "ab".repeat(32);
let e = DownloadError::transport(&peer, "connection refused");
assert!(e.to_string().contains(&peer));
assert!(e.to_string().contains("connection refused"));
assert!(e.is_recoverable());
}
#[test]
fn a_hostile_provider_id_and_reason_are_never_echoed() {
let hostile = "not-hex <script>x</script>\n[FATAL] forged log line";
let rendered =
DownloadError::transport(hostile, "remote said: \n[FATAL] also forged").to_string();
assert!(
rendered.contains("<non-canonical-peer-id>"),
"the id is sentinelled: {rendered}"
);
assert!(
!rendered.contains("<script>"),
"no peer-supplied id text: {rendered}"
);
assert!(
!rendered.contains('\n'),
"a foreign reason can never forge a second log line: {rendered}"
);
let direct = DownloadError::Transport {
provider: hostile.to_string(),
reason: "x\ny".to_string(),
}
.to_string();
assert!(!direct.contains('\n') && !direct.contains("<script>"));
}
#[test]
fn a_hostile_verify_reason_can_never_forge_a_log_line() {
let hostile = "root deadbeef\n[FATAL] forged by a peer != committed abc";
let rendered =
DownloadError::Verify(VerifyError::Metadata(hostile.to_string())).to_string();
assert!(
!rendered.contains('\n'),
"a wrapped verify reason forges a second line: {rendered}"
);
assert!(
rendered.contains("deadbeef"),
"still diagnosable: {rendered}"
);
}
#[test]
fn a_bare_verify_error_debug_is_sanitized_too() {
let hostile = "root deadbeef\n[FATAL] forged by a peer != committed abc";
let rendered = format!("{:?}", VerifyError::Metadata(hostile.to_string()));
assert!(
!rendered.contains('\n'),
"a bare Debug forges a second line: {rendered}"
);
assert!(
rendered.contains("deadbeef"),
"still diagnosable: {rendered}"
);
}
#[test]
fn bidi_overrides_are_escaped_like_control_characters() {
let sanitized = sanitize_untrusted_text("safe\u{202E}dorp.exe", 64);
assert!(
!sanitized.contains('\u{202E}'),
"the override survived: {sanitized}"
);
assert!(sanitized.contains("safe"), "still diagnosable: {sanitized}");
}
#[test]
fn untrusted_text_is_escaped_and_bounded() {
assert_eq!(sanitize_untrusted_text("a\nb", 64), "a\\nb");
assert_eq!(sanitize_untrusted_text("héllo", 64), "héllo");
let long = sanitize_untrusted_text(&"x".repeat(100), 10);
assert_eq!(long, format!("{}…<truncated>", "x".repeat(10)));
}
#[test]
fn untrusted_ids_are_sentinelled() {
let canonical = "ab".repeat(32);
assert_eq!(hex64_or_sentinel(&canonical, "peer-id"), canonical);
assert_eq!(
hex64_or_sentinel(&"AB".repeat(32), "peer-id"),
canonical,
"canonical form is lowercase"
);
assert_eq!(
hex64_or_sentinel("short", "peer-id"),
"<non-canonical-peer-id>"
);
assert_eq!(
hex64_or_sentinel(&"zz".repeat(32), "hash"),
"<non-canonical-hash>"
);
}
#[test]
fn verify_errors_are_recoverable() {
let e: DownloadError = VerifyError::Length {
expected: 10,
actual: 9,
}
.into();
assert!(e.is_recoverable());
}
#[test]
fn timeout_is_recoverable() {
let peer = "cd".repeat(32);
let e = DownloadError::Timeout {
provider: peer.clone(),
};
assert!(e.is_recoverable());
assert!(e.to_string().contains(&peer));
assert!(e.to_string().contains("timed out"));
}
#[test]
fn terminal_errors_are_not_recoverable() {
assert!(!DownloadError::NoProviders { needed: 1 }.is_recoverable());
assert!(!DownloadError::Cancelled.is_recoverable());
assert!(!DownloadError::NotDownloadable.is_recoverable());
}
#[test]
fn sink_and_state_helpers_format() {
assert!(DownloadError::sink("disk full")
.to_string()
.contains("disk full"));
assert!(DownloadError::state("corrupt")
.to_string()
.contains("corrupt"));
}
#[test]
fn verify_error_display_is_descriptive() {
assert!(VerifyError::Root
.to_string()
.contains("chain-anchored root"));
assert!(VerifyError::Metadata("x".into())
.to_string()
.contains("commitment"));
assert!(VerifyError::Alignment("y".into())
.to_string()
.contains("chunk-aligned"));
assert!(VerifyError::MissingMetadata("z".into())
.to_string()
.contains("missing verification metadata"));
}
}