Add exact-identity target storage adapter
This commit is contained in:
parent
f569707f97
commit
854e6e1c17
1 changed files with 3263 additions and 41 deletions
186
crates/remora-target/src/error.rs
Normal file
186
crates/remora-target/src/error.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
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]"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue