186 lines
6.4 KiB
Rust
186 lines
6.4 KiB
Rust
|
|
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]"));
|
||
|
|
}
|
||
|
|
}
|