use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
use reqwest::blocking::Client;
mod auth;
mod listing;
use auth::AwsSigV4Config;
use listing::{parse_s3_listing, ListBucketResult, ListObject};
pub struct S3Source {
bucket: String,
prefix: Option<String>,
endpoint: Option<String>,
max_objects: Option<usize>,
limits: crate::SourceLimits,
http: crate::http::HttpClientConfig,
allow_credential_forward: bool,
}
impl S3Source {
pub fn new(bucket: impl Into<String>) -> Self {
Self {
bucket: bucket.into(),
prefix: None,
endpoint: None,
max_objects: None,
limits: crate::SourceLimits::default(),
http: crate::http::HttpClientConfig {
ua_suffix: Some("s3".into()),
..Default::default()
},
allow_credential_forward: false,
}
}
pub(crate) fn with_http_config(mut self, http: crate::http::HttpClientConfig) -> Self {
self.http = http;
self
}
pub(crate) fn with_allow_credential_forward(mut self, allow: bool) -> Self {
self.allow_credential_forward = allow;
self
}
pub(crate) fn with_limits(mut self, limits: crate::SourceLimits) -> Self {
self.limits = limits;
self
}
pub(crate) fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
crate::cloud::set_optional(&mut self.prefix, prefix.into());
self
}
pub(crate) fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub(crate) fn with_max_objects(mut self, max_objects: usize) -> Self {
crate::cloud::set_optional(&mut self.max_objects, max_objects);
self
}
}
impl Source for S3Source {
fn name(&self) -> &str {
"s3"
}
fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
crate::gate_scan(|| {
let result = crate::cloud::collect_on_blocking_thread("s3", || {
collect_s3_chunks(
&self.bucket,
self.prefix.as_deref(),
self.endpoint.as_deref(),
match self.max_objects {
Some(max_objects) => max_objects,
None => self.limits.cloud_max_objects, },
self.limits,
&self.http,
self.allow_credential_forward,
)
});
match result {
Ok(rows) => Box::new(rows.into_iter()),
Err(error) => Box::new(std::iter::once(Err(error))),
}
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
fn collect_s3_chunks(
bucket: &str,
prefix: Option<&str>,
endpoint: Option<&str>,
max_objects: usize,
limits: crate::SourceLimits,
http: &crate::http::HttpClientConfig,
allow_credential_forward: bool,
) -> Result<Vec<Result<Chunk, SourceError>>, SourceError> {
let bucket = validate_bucket_name(bucket)?;
let client = crate::cloud::blocking_client("S3", http)?;
let base_url = build_base_url(&bucket, endpoint, http.allow_private_endpoint)?;
let aws_auth = resolve_s3_auth(&base_url, endpoint, allow_credential_forward)?;
let mut continuation_token = None::<String>;
let mut chunks = Vec::new();
let mut coverage = crate::cloud::CloudListingCoverage::new("s3", "objects", max_objects);
let fetch_pool = crate::cloud::object_fetch_pool("s3")?;
loop {
if !coverage.has_capacity_or_record(&mut chunks) {
break;
}
let listing = fetch_s3_listing_page(
&client,
&base_url,
prefix,
continuation_token.as_deref(),
aws_auth.as_ref(),
limits.web_response_bytes,
)?;
let (page, reached_limit) = coverage.take_page(listing.contents);
let page_chunks = download_s3_listing_page(
&fetch_pool,
&page,
&client,
&base_url,
&bucket,
aws_auth.as_ref(),
limits.s3_object_bytes,
);
crate::cloud::push_page_chunks(&mut chunks, page_chunks);
if reached_limit || !listing.is_truncated {
if reached_limit {
coverage.record_truncated(
&mut chunks,
"max_objects limit reached within the current S3 listing page",
);
}
break;
}
continuation_token =
crate::cloud::meaningful_continuation_token(listing.next_continuation_token.as_deref())
.map(str::to_string);
if continuation_token.is_none() {
coverage.record_truncated(
&mut chunks,
"S3 listing response was truncated but omitted or emptied NextContinuationToken",
);
break;
}
}
Ok(chunks)
}
fn resolve_s3_auth(
base_url: &str,
endpoint: Option<&str>,
allow_credential_forward: bool,
) -> Result<Option<AwsSigV4Config>, SourceError> {
let endpoint_is_aws_host = match endpoint {
Some(value) => endpoint_is_aws(value),
None => true,
};
if endpoint_is_aws_host {
return AwsSigV4Config::from_env(base_url);
}
if crate::cloud::credential_forward_allowed(allow_credential_forward) {
tracing::warn!(
endpoint = %endpoint.unwrap_or(""), "explicit S3 credential-forwarding override active: forwarding \
ambient AWS credentials to non-AWS endpoint. Verify you trust this host."
);
return AwsSigV4Config::from_env(base_url);
}
if ambient_s3_credentials_present() {
let endpoint_display = match endpoint {
Some(endpoint) => endpoint,
None => "<default AWS endpoint>",
};
return Err(SourceError::Other(format!(
"AWS credentials are present but endpoint {} is non-AWS; refusing to run anonymously after dropping credentials. Pass the explicit S3 credential-forwarding flag only for endpoints you trust, or unset AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY for anonymous S3-compatible scans.",
endpoint_display
)));
}
Ok(None)
}
fn ambient_s3_credentials_present() -> bool {
[
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
]
.iter()
.any(|name| std::env::var_os(name).is_some())
}
fn fetch_s3_listing_page(
client: &Client,
base_url: &str,
prefix: Option<&str>,
continuation_token: Option<&str>,
aws_auth: Option<&AwsSigV4Config>,
max_response_bytes: usize,
) -> Result<ListBucketResult, SourceError> {
let mut request = client.get(base_url).query(&[("list-type", "2")]);
if let Some(prefix) = prefix {
request = request.query(&[("prefix", prefix)]);
}
if let Some(token) = continuation_token {
request = request.query(&[("continuation-token", token)]);
}
if let Some(auth) = aws_auth {
request = auth.sign(request, base_url)?;
}
let response = request.send().map_err(|error| {
crate::cloud::record_unreadable_listing_skip(
"S3",
"objects",
format!("failed to list objects: {error}"),
)
})?;
if !response.status().is_success() {
let status = response.status();
return Err(crate::cloud::record_unreadable_listing_skip(
"S3",
"objects",
format!("bucket request returned {status}"),
));
}
let body =
crate::cloud::read_listing_response_body(response, "S3", "objects", max_response_bytes)?;
parse_s3_listing(&body).map_err(|error| {
crate::cloud::record_unreadable_listing_skip(
"S3",
"objects",
format!("failed to parse listing response: {error}"),
)
})
}
fn download_s3_listing_page(
fetch_pool: &rayon::ThreadPool,
page: &[ListObject],
client: &Client,
base_url: &str,
bucket: &str,
aws_auth: Option<&AwsSigV4Config>,
max_object_bytes: u64,
) -> Vec<Result<Option<Chunk>, SourceError>> {
use rayon::prelude::*;
fetch_pool.install(|| {
page.par_iter()
.map(|object| -> Result<Option<Chunk>, SourceError> {
match object.size {
Some(0) => return Ok(None),
Some(_) | None => {}
}
if !crate::cloud::is_probably_text_object_key(&object.key) {
tracing::warn!(
bucket = %bucket,
key = %object.key,
"skipping S3 object: extension is treated as binary/container content; NOT scanned as text",
);
return Err(crate::cloud::record_unscanned_object_skip(
crate::SourceSkipEvent::Binary,
"S3 object",
"object",
&format!("s3://{bucket}/{}", object.key),
"extension is treated as binary/container content",
));
}
fetch_object_chunk(
client,
base_url,
bucket,
&object.key,
object.size,
aws_auth,
max_object_bytes,
)
})
.collect()
})
}
fn fetch_object_chunk(
client: &Client,
base_url: &str,
bucket: &str,
key: &str,
listed_size: Option<u64>,
aws_auth: Option<&AwsSigV4Config>,
max_object_bytes: u64,
) -> Result<Option<Chunk>, SourceError> {
if let Some(object_size) = listed_size {
if object_size > max_object_bytes {
tracing::warn!(
bucket,
key,
object_size,
cap = max_object_bytes,
"skipping S3 object: listed size exceeds the per-object byte cap; NOT scanned",
);
return Err(crate::cloud::record_unscanned_object_skip(
crate::SourceSkipEvent::OverMaxSize,
"S3 object",
"object",
&format!("s3://{bucket}/{key}"),
format!(
"listed size {object_size} exceeds the per-object byte cap {max_object_bytes}"
),
));
}
}
let encoded_key = crate::cloud::encode_object_key_path(key);
let url = format!("{}/{}", base_url.trim_end_matches('/'), encoded_key);
let display_path = format!("s3://{bucket}/{key}");
let mut request = client.get(&url);
if listed_size.is_none() && max_object_bytes > 0 {
let end = max_object_bytes.saturating_sub(1);
request = request.header("Range", format!("bytes=0-{end}"));
}
let request = if let Some(auth) = aws_auth {
auth.sign(request, &url)?
} else {
request
};
let response = request.send().map_err(|error| {
crate::cloud::record_unreadable_object_skip(
"S3 object",
"object",
&display_path,
format!("download failed for {key}: {error}"),
)
})?;
let Some(object_text) = crate::cloud::read_text_object_body(
response,
crate::cloud::TextObjectBodyContext {
source: "S3 object",
item_kind: "object",
item_name: key,
display_path,
max_bytes: max_object_bytes,
},
)?
else {
return Ok(None);
};
Ok(Some(Chunk {
data: object_text.into(),
metadata: ChunkMetadata {
base_offset: 0,
base_line: 0,
source_type: "s3".into(),
path: Some(format!("{bucket}/{key}").into()),
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: None,
decoded_span: None,
},
}))
}
pub(crate) fn endpoint_is_aws(endpoint: &str) -> bool {
crate::cloud::endpoint_host_matches_domain(endpoint, "amazonaws.com")
|| crate::cloud::endpoint_host_matches_domain(endpoint, "amazonaws.com.cn")
}
fn build_base_url(
bucket: &str,
endpoint: Option<&str>,
allow_private: bool,
) -> Result<String, SourceError> {
match endpoint {
Some(endpoint) => {
let endpoint =
crate::cloud::validate_cloud_endpoint(endpoint, "S3", allow_private, false)?;
Ok(format!(
"{}/{}",
endpoint.trim_end_matches('/'),
urlencoding::encode(bucket)
))
}
None => Ok(format!(
"https://{bucket}.{}",
crate::cloud::DEFAULT_S3_HOST_SUFFIX
)),
}
}
const S3_BUCKET_NAME_MIN_LEN: usize = 3;
const S3_BUCKET_NAME_MAX_LEN: usize = 63;
fn validate_bucket_name(bucket: &str) -> Result<String, SourceError> {
let bucket = bucket.trim();
if bucket.len() < S3_BUCKET_NAME_MIN_LEN || bucket.len() > S3_BUCKET_NAME_MAX_LEN {
return Err(SourceError::Other("invalid S3 bucket name length".into()));
}
if bucket.starts_with('.')
|| bucket.ends_with('.')
|| bucket.starts_with('-')
|| bucket.ends_with('-')
|| bucket.contains("..")
|| bucket.contains('/')
|| bucket.chars().any(char::is_control)
{
return Err(SourceError::Other(format!("invalid S3 bucket '{bucket}'")));
}
if !bucket
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '-'))
{
return Err(SourceError::Other(format!("invalid S3 bucket '{bucket}'")));
}
Ok(bucket.to_string())
}
#[cfg(test)]
mod builder_setter_tests {
use super::S3Source;
#[test]
fn with_prefix_and_max_objects_route_through_shared_set_optional() {
let source = S3Source::new("bucket-name");
assert_eq!(source.prefix, None);
assert_eq!(source.max_objects, None);
let source = source.with_prefix("archive/").with_max_objects(3);
assert_eq!(source.prefix.as_deref(), Some("archive/"));
assert_eq!(source.max_objects, Some(3));
let source = source.with_prefix("current/").with_max_objects(128);
assert_eq!(source.prefix.as_deref(), Some("current/"));
assert_eq!(source.max_objects, Some(128));
}
}