use super::remote::MAX_SOURCE_BYTES;
use super::response::{
HttpResponse, bad_gateway_response, bad_request_response, payload_too_large_response,
};
pub struct S3Context {
pub client: aws_sdk_s3::Client,
pub default_bucket: String,
pub endpoint_url: Option<String>,
runtime: tokio::runtime::Runtime,
}
impl std::fmt::Debug for S3Context {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("S3Context")
.field("default_bucket", &self.default_bucket)
.field("endpoint_url", &self.endpoint_url)
.field("client", &"..")
.finish()
}
}
impl S3Context {
pub fn check_reachable(&self) -> bool {
use aws_sdk_s3::error::SdkError;
use std::time::Duration;
let client = self.client.clone();
let bucket = self.default_bucket.clone();
self.runtime.block_on(async {
let result = tokio::time::timeout(
Duration::from_secs(2),
client
.get_object()
.bucket(&bucket)
.key("__truss_health_probe__")
.send(),
)
.await;
match result {
Ok(Ok(_)) => true,
Ok(Err(SdkError::ServiceError(e))) => {
e.err().meta().code() != Some("NoSuchBucket")
}
_ => false,
}
})
}
}
#[cfg(test)]
impl S3Context {
pub(crate) fn for_test(default_bucket: &str, endpoint_url: Option<&str>) -> Self {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let sdk_config = runtime.block_on(aws_config::load_defaults(
aws_config::BehaviorVersion::latest(),
));
let client = aws_sdk_s3::Client::new(&sdk_config);
S3Context {
client,
default_bucket: default_bucket.to_string(),
endpoint_url: endpoint_url.map(|s| s.to_string()),
runtime,
}
}
}
pub fn build_s3_context(
default_bucket: String,
allow_insecure: bool,
) -> Result<S3Context, std::io::Error> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()?;
let sdk_config = runtime.block_on(aws_config::load_defaults(
aws_config::BehaviorVersion::latest(),
));
let endpoint_url = sdk_config.endpoint_url().map(|s| s.to_string());
if let Some(ref url) = endpoint_url {
super::remote::validate_backend_endpoint_url(url, "AWS_ENDPOINT_URL", allow_insecure)?;
}
let force_path_style = matches!(
std::env::var("TRUSS_S3_FORCE_PATH_STYLE")
.unwrap_or_default()
.to_ascii_lowercase()
.as_str(),
"1" | "true" | "yes" | "on"
);
let s3_config = aws_sdk_s3::config::Builder::from(&sdk_config)
.force_path_style(force_path_style)
.build();
let client = aws_sdk_s3::Client::from_conf(s3_config);
Ok(S3Context {
client,
default_bucket,
endpoint_url,
runtime,
})
}
pub(super) fn read_s3_source_bytes(
bucket: &str,
key: &str,
s3: &S3Context,
timeout_secs: u64,
) -> Result<Vec<u8>, HttpResponse> {
validate_s3_key(key)?;
s3.runtime.block_on(async {
let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), async {
let output = s3
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.map_err(map_s3_get_object_error)?;
if let Some(len) = output.content_length()
&& len as u64 > MAX_SOURCE_BYTES
{
return Err(payload_too_large_response(
"S3 object exceeds the source size limit",
));
}
let capacity_hint = output
.content_length()
.map(|l| (l as usize).min(MAX_SOURCE_BYTES as usize + 1))
.unwrap_or(0);
use tokio::io::AsyncReadExt;
let mut limited = output.body.into_async_read().take(MAX_SOURCE_BYTES + 1);
let mut buf = Vec::with_capacity(capacity_hint);
limited.read_to_end(&mut buf).await.map_err(|e| {
super::stderr_write(&format!("s3 error: failed to read object body: {e}"));
bad_gateway_response("failed to read S3 object body")
})?;
if buf.len() as u64 > MAX_SOURCE_BYTES {
return Err(payload_too_large_response(
"S3 object exceeds the source size limit",
));
}
Ok(buf)
})
.await;
match result {
Ok(inner) => inner,
Err(_) => {
super::stderr_write(&format!(
"s3 error: download timed out after {timeout_secs}s"
));
Err(bad_gateway_response("object storage download timed out"))
}
}
})
}
fn validate_s3_key(key: &str) -> Result<(), HttpResponse> {
if key.is_empty() {
return Err(bad_request_response("S3 key must not be empty"));
}
if key.contains('\0') || key.contains('\n') || key.contains('\r') {
return Err(bad_request_response(
"S3 key contains invalid characters (null, newline, or carriage return)",
));
}
if key.len() > 1024 {
return Err(bad_request_response(
"S3 key exceeds the maximum allowed length of 1024 bytes",
));
}
Ok(())
}
fn map_s3_get_object_error(
err: aws_sdk_s3::error::SdkError<aws_sdk_s3::operation::get_object::GetObjectError>,
) -> HttpResponse {
use aws_sdk_s3::error::SdkError;
match &err {
SdkError::ServiceError(service_err) => {
if service_err.err().is_no_such_key() {
return super::response::not_found_response(
"source image was not found in object storage",
);
}
if service_err.raw().status().as_u16() == 403 {
return super::response::forbidden_response(
"access denied by object storage — check IAM permissions",
);
}
if service_err.raw().status().as_u16() == 401 {
return bad_gateway_response(
"object storage authentication failed — check credentials",
);
}
super::stderr_write(&format!("s3 error: {err}"));
bad_gateway_response("object storage returned an error")
}
_ => {
super::stderr_write(&format!("s3 error: {err}"));
bad_gateway_response("object storage request failed")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_s3_key_valid() {
assert!(validate_s3_key("images/photo.jpg").is_ok());
assert!(validate_s3_key("a").is_ok());
assert!(validate_s3_key("path/to/deep/object.png").is_ok());
}
#[test]
fn test_validate_s3_key_rejects_empty() {
assert!(validate_s3_key("").is_err());
}
#[test]
fn test_validate_s3_key_rejects_null() {
assert!(validate_s3_key("foo\0bar").is_err());
}
#[test]
fn test_validate_s3_key_rejects_newline() {
assert!(validate_s3_key("foo\nbar").is_err());
assert!(validate_s3_key("foo\rbar").is_err());
}
#[test]
fn test_validate_s3_key_rejects_too_long() {
let long_key = "a".repeat(1025);
assert!(validate_s3_key(&long_key).is_err());
let max_key = "a".repeat(1024);
assert!(validate_s3_key(&max_key).is_ok());
}
#[test]
fn test_validate_s3_key_allows_dot_segments() {
assert!(validate_s3_key("../etc/passwd").is_ok());
assert!(validate_s3_key("images/../secret").is_ok());
assert!(validate_s3_key("..").is_ok());
assert!(validate_s3_key("a..b/file.jpg").is_ok());
assert!(validate_s3_key(".hidden/file.jpg").is_ok());
}
#[test]
fn test_validate_s3_key_allows_unicode() {
assert!(validate_s3_key("images/\u{5199}\u{771f}.jpg").is_ok());
assert!(validate_s3_key("données/fichier.png").is_ok());
}
#[test]
fn test_validate_s3_key_allows_special_chars() {
assert!(validate_s3_key("path/to/file name.jpg").is_ok());
assert!(validate_s3_key("a+b=c.jpg").is_ok());
assert!(validate_s3_key("foo\tbar").is_ok());
}
fn s3_service_error(
err: aws_sdk_s3::operation::get_object::GetObjectError,
status: u16,
) -> aws_sdk_s3::error::SdkError<aws_sdk_s3::operation::get_object::GetObjectError> {
use aws_smithy_runtime_api::http::{Response, StatusCode};
use aws_smithy_types::body::SdkBody;
let raw = Response::new(StatusCode::try_from(status).unwrap(), SdkBody::empty());
aws_sdk_s3::error::SdkError::service_error(err, raw)
}
#[test]
fn test_map_s3_error_no_such_key_returns_not_found() {
let err = aws_sdk_s3::operation::get_object::GetObjectError::NoSuchKey(
aws_sdk_s3::types::error::NoSuchKey::builder().build(),
);
let resp = map_s3_get_object_error(s3_service_error(err, 404));
assert_eq!(resp.status, "404 Not Found");
}
#[test]
fn test_map_s3_error_403_returns_forbidden() {
let err = aws_sdk_s3::operation::get_object::GetObjectError::unhandled("access denied");
let resp = map_s3_get_object_error(s3_service_error(err, 403));
assert_eq!(resp.status, "403 Forbidden");
}
#[test]
fn test_map_s3_error_401_returns_bad_gateway() {
let err = aws_sdk_s3::operation::get_object::GetObjectError::unhandled("unauthorized");
let resp = map_s3_get_object_error(s3_service_error(err, 401));
assert_eq!(resp.status, "502 Bad Gateway");
}
#[test]
fn test_map_s3_error_500_returns_bad_gateway() {
let err =
aws_sdk_s3::operation::get_object::GetObjectError::unhandled("internal server error");
let resp = map_s3_get_object_error(s3_service_error(err, 500));
assert_eq!(resp.status, "502 Bad Gateway");
}
#[test]
fn test_map_s3_error_non_service_returns_bad_gateway() {
let err = aws_sdk_s3::error::SdkError::<
aws_sdk_s3::operation::get_object::GetObjectError,
>::dispatch_failure(
aws_smithy_runtime_api::client::result::ConnectorError::other(
"connection refused".into(),
None,
),
);
let resp = map_s3_get_object_error(err);
assert_eq!(resp.status, "502 Bad Gateway");
}
}