remora/crates/remora-target/src/error.rs

186 lines
6.4 KiB
Rust
Raw Normal View History

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<String>) -> Self {
Self::Validation(detail.into())
}
pub fn integrity(detail: impl Into<String>) -> Self {
Self::Integrity(detail.into())
}
pub fn transient(detail: impl Into<String>) -> Self {
Self::Transient(detail.into())
}
pub fn internal(detail: impl Into<String>) -> 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<TargetError> 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<str>) -> 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]"));
}
}