use remora_stream::{StreamError, StreamSide}; /// Target storage adapter errors. /// /// Diagnostic text is for operator logs only after secret scrubbing. Public /// projections go through [`remora_stream::classify_stream_error`]. #[derive(Debug, thiserror::Error)] pub enum TargetError { #[error("target authorization failure")] Authorization, #[error("object already exists under create-only identity")] AlreadyExists, #[error("object not found")] NotFound, #[error("integrity failure: {0}")] Integrity(String), #[error("capacity exhausted on target")] Capacity, #[error("transient target failure: {0}")] Transient(String), #[error("validation failure: {0}")] Validation(String), #[error("internal target error: {0}")] Internal(String), } impl TargetError { pub fn validation(detail: impl Into) -> Self { Self::Validation(detail.into()) } pub fn integrity(detail: impl Into) -> Self { Self::Integrity(detail.into()) } pub fn transient(detail: impl Into) -> Self { Self::Transient(detail.into()) } pub fn internal(detail: impl Into) -> Self { Self::Internal(detail.into()) } /// Map to a stream error for copy-loop and adapter I/O boundaries. #[must_use] pub fn into_stream_error(self) -> StreamError { match self { Self::Authorization => StreamError::Authorization { side: StreamSide::Target, }, Self::AlreadyExists => StreamError::integrity("create-only identity already exists"), Self::NotFound => StreamError::internal("target object not found"), Self::Integrity(detail) => StreamError::Integrity { detail }, Self::Capacity => StreamError::Capacity { side: StreamSide::Target, }, Self::Transient(detail) => StreamError::Transient { side: StreamSide::Target, detail, }, Self::Validation(detail) => StreamError::internal(detail), Self::Internal(detail) => StreamError::Internal { detail }, } } } impl From for StreamError { fn from(value: TargetError) -> Self { value.into_stream_error() } } /// Map OpenDAL errors without embedding raw exception bodies in public paths. pub(crate) fn map_opendal(error: opendal::Error) -> TargetError { use opendal::ErrorKind; let detail = redact_sensitive(error.to_string()); match error.kind() { ErrorKind::NotFound => TargetError::NotFound, ErrorKind::PermissionDenied => TargetError::Authorization, ErrorKind::AlreadyExists | ErrorKind::ConditionNotMatch => TargetError::AlreadyExists, ErrorKind::RateLimited => TargetError::Capacity, ErrorKind::Unsupported | ErrorKind::ConfigInvalid => TargetError::Validation(detail), ErrorKind::Unexpected | ErrorKind::IsADirectory | ErrorKind::NotADirectory | ErrorKind::IsSameFile | ErrorKind::RangeNotSatisfied => { if is_likely_transient(&detail) { TargetError::Transient(detail) } else { TargetError::Internal(detail) } } _ => TargetError::Internal(detail), } } pub(crate) fn map_http_status(status: u16, body: &str) -> TargetError { let detail = redact_sensitive(body); match status { 401 | 403 => TargetError::Authorization, 404 => TargetError::NotFound, 409 | 412 => TargetError::AlreadyExists, 429 | 503 => TargetError::Capacity, 500 | 502 | 504 => TargetError::Transient(format!("http {status}: {detail}")), _ if status >= 500 => TargetError::Transient(format!("http {status}: {detail}")), _ => TargetError::Internal(format!("http {status}: {detail}")), } } /// Scrub credential-bearing fragments from operator-facing diagnostic text. pub(crate) fn redact_sensitive(input: impl AsRef) -> String { let mut out = input.as_ref().to_owned(); const NEEDLES: &[&str] = &[ "Authorization:", "authorization:", "AWSAccessKeyId=", "X-Amz-Credential=", "X-Amz-Signature=", "X-Amz-Security-Token=", "x-amz-security-token=", "Credential=", "Signature=", "SecretAccessKey", "secret_access_key", "secret-access-key", "Bearer ", ]; while let Some((idx, needle)) = NEEDLES .iter() .filter_map(|needle| out.find(needle).map(|idx| (idx, *needle))) .min_by_key(|(idx, _)| *idx) { let value_start = idx + needle.len(); // Authorization headers are multi-token; scrub through end of line. let value_end = if needle.eq_ignore_ascii_case("Authorization:") { out[value_start..] .find(['\r', '\n']) .map(|offset| value_start + offset) .unwrap_or(out.len()) } else { out[value_start..] .find(|character: char| { character.is_whitespace() || matches!(character, '"' | '\'' | ',' | ';' | '&' | '<' | '>') }) .map(|offset| value_start + offset) .unwrap_or(out.len()) }; out.replace_range(idx..value_end, "[redacted]"); } if out.len() > 512 { out.truncate(512); out.push('…'); } out } fn is_likely_transient(detail: &str) -> bool { let lower = detail.to_ascii_lowercase(); lower.contains("timeout") || lower.contains("temporar") || lower.contains("connection") || lower.contains("reset") || lower.contains("unavailable") || lower.contains("slow down") } #[cfg(test)] mod tests { use super::*; #[test] fn redact_sensitive_scrubs_long_and_repeated_secrets() { let long_sig = "a".repeat(80); let input = format!( "X-Amz-Signature={long_sig}&X-Amz-Credential=AKIAEXAMPLE/path Authorization: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE, SignedHeaders=host, Signature={long_sig} Bearer super-secret-token-value-here" ); let redacted = redact_sensitive(input); assert!(!redacted.contains("AKIAEXAMPLE")); assert!(!redacted.contains(&long_sig)); assert!(!redacted.contains("super-secret-token-value-here")); assert!(redacted.contains("[redacted]")); } }