use bytesize::ByteSize;
use serde::{Deserialize, Serialize};
use spate_core::config::{ComponentConfig, ConfigError};
use std::collections::BTreeMap;
use url::Url;
const DENYLIST: &[(&str, &str)] = &[
("bucket", "owned by the typed `url` field"),
("bucket_name", "owned by the typed `url` field"),
("aws_bucket", "owned by the typed `url` field"),
("aws_bucket_name", "owned by the typed `url` field"),
];
fn default_prefetch_bytes() -> ByteSize {
ByteSize::mib(8)
}
fn default_chunk_bytes() -> ByteSize {
ByteSize::kib(512)
}
fn default_split_target_bytes() -> ByteSize {
ByteSize::mib(64)
}
const SPLIT_TARGET_FLOOR: u64 = 1024 * 1024;
struct Redacted<'a>(&'a BTreeMap<String, String>);
impl std::fmt::Debug for Redacted<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_map()
.entries(self.0.keys().map(|k| (k, "<redacted>")))
.finish()
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Compression {
#[default]
Auto,
None,
Gzip,
Zstd,
}
#[derive(Clone, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct S3SourceConfig {
pub url: String,
#[serde(default)]
pub compression: Compression,
#[serde(default = "default_split_target_bytes")]
pub split_target_bytes: ByteSize,
#[serde(default)]
pub refresh_listing: bool,
#[serde(default = "default_prefetch_bytes")]
pub prefetch_bytes: ByteSize,
#[serde(default = "default_chunk_bytes")]
pub chunk_bytes: ByteSize,
#[serde(default)]
pub store: BTreeMap<String, String>,
}
impl std::fmt::Debug for S3SourceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("S3SourceConfig")
.field("url", &self.url)
.field("compression", &self.compression)
.field("split_target_bytes", &self.split_target_bytes)
.field("refresh_listing", &self.refresh_listing)
.field("prefetch_bytes", &self.prefetch_bytes)
.field("chunk_bytes", &self.chunk_bytes)
.field("store", &Redacted(&self.store))
.finish()
}
}
impl S3SourceConfig {
pub fn from_component_config(section: &ComponentConfig) -> Result<Self, ConfigError> {
let cfg: S3SourceConfig = section.deserialize_into()?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<(), ConfigError> {
parse_url("source.s3.url", &self.url)?;
if self.chunk_bytes.as_u64() == 0 {
return Err(ConfigError::Validation(
"source.s3.chunk_bytes must not be zero".into(),
));
}
if self.prefetch_bytes.as_u64() < self.chunk_bytes.as_u64() {
return Err(ConfigError::Validation(format!(
"source.s3.prefetch_bytes ({}) must be at least chunk_bytes ({})",
self.prefetch_bytes, self.chunk_bytes
)));
}
if self.split_target_bytes.as_u64() < SPLIT_TARGET_FLOOR {
return Err(ConfigError::Validation(format!(
"source.s3.split_target_bytes ({}) must be at least 1MiB",
self.split_target_bytes
)));
}
for key in self.store.keys() {
let lowered = key.to_ascii_lowercase();
if let Some((_, why)) = DENYLIST.iter().find(|(denied, _)| *denied == lowered) {
return Err(ConfigError::Validation(format!(
"source.s3.store.\"{key}\" cannot be overridden: {why}"
)));
}
}
Ok(())
}
}
fn parse_url(field: &str, value: &str) -> Result<Url, ConfigError> {
if value.trim().is_empty() {
return Err(ConfigError::Validation(format!(
"{field} must not be empty"
)));
}
let url = Url::parse(value)
.map_err(|e| ConfigError::Validation(format!("{field} is not a valid URL: {e}")))?;
if url.scheme() == "memory" {
return Err(ConfigError::Validation(format!(
"{field}: memory:// creates a new empty store per component and cannot be \
shared; use file:// for infrastructure-free runs"
)));
}
Ok(url)
}
#[cfg(test)]
mod tests {
use super::*;
fn section(body: &str) -> ComponentConfig {
let yaml = format!("s3:\n{body}");
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
ComponentConfig::new("s3", value["s3"].clone())
}
fn minimal() -> String {
" url: s3://bucket/exports/\n".to_string()
}
#[test]
fn minimal_config_gets_documented_defaults() {
let cfg = S3SourceConfig::from_component_config(§ion(&minimal())).unwrap();
assert_eq!(cfg.compression, Compression::Auto);
assert_eq!(cfg.prefetch_bytes, ByteSize::mib(8));
assert_eq!(cfg.chunk_bytes, ByteSize::kib(512));
assert_eq!(cfg.split_target_bytes, ByteSize::mib(64));
assert!(!cfg.refresh_listing);
assert!(cfg.store.is_empty());
}
#[test]
fn planner_knobs_parse_and_floor() {
let body = format!(
"{} split_target_bytes: 32MB\n refresh_listing: true\n",
minimal()
);
let cfg = S3SourceConfig::from_component_config(§ion(&body)).unwrap();
assert_eq!(cfg.split_target_bytes, ByteSize::mb(32));
assert!(cfg.refresh_listing);
let low = format!("{} split_target_bytes: 64KB\n", minimal());
let err = S3SourceConfig::from_component_config(§ion(&low)).unwrap_err();
assert!(err.to_string().contains("split_target_bytes"), "{err}");
}
#[test]
fn removed_knobs_are_rejected() {
for extra in [
" lanes: 4\n",
" checkpoint:\n url: s3://bucket/_etl/b.json\n",
" coordination:\n nats:\n servers: [\"nats://n:4222\"]\n",
] {
let body = format!("{}{extra}", minimal());
assert!(
S3SourceConfig::from_component_config(§ion(&body)).is_err(),
"must reject removed knob: {extra}"
);
}
}
#[test]
fn debug_never_prints_store_values() {
let body = format!(
"{} store:\n aws_secret_access_key: hunter2\n",
minimal()
);
let cfg = S3SourceConfig::from_component_config(§ion(&body)).unwrap();
let printed = format!("{cfg:?}");
assert!(!printed.contains("hunter2"), "{printed}");
assert!(
printed.contains("aws_secret_access_key") && printed.contains("<redacted>"),
"keys stay visible for debugging: {printed}"
);
}
#[test]
fn bucket_aliases_are_rejected_in_the_passthrough() {
for key in [
"bucket",
"bucket_name",
"aws_bucket",
"aws_bucket_name",
"Bucket",
"AWS_BUCKET_NAME",
] {
let body = format!("{} store:\n {key}: other\n", minimal());
let err = S3SourceConfig::from_component_config(§ion(&body)).unwrap_err();
assert!(err.to_string().contains(key), "error names the key: {err}");
}
}
#[test]
fn memory_scheme_is_rejected_with_guidance() {
let err = S3SourceConfig::from_component_config(§ion(" url: memory:///data/\n"))
.unwrap_err();
assert!(err.to_string().contains("file://"), "actionable: {err}");
}
#[test]
fn bad_buffer_sizes_are_rejected() {
for extra in [
" chunk_bytes: 0\n",
" prefetch_bytes: 4KiB\n chunk_bytes: 1MiB\n",
] {
let body = format!("{}{extra}", minimal());
assert!(
S3SourceConfig::from_component_config(§ion(&body)).is_err(),
"must reject: {extra}"
);
}
}
#[test]
fn unknown_fields_and_unknown_variants_are_rejected() {
for extra in [" prefix: also-here\n", " compression: xz\n"] {
let body = format!("{}{extra}", minimal());
assert!(
S3SourceConfig::from_component_config(§ion(&body)).is_err(),
"must reject: {extra}"
);
}
}
#[test]
fn file_urls_are_accepted() {
S3SourceConfig::from_component_config(§ion(" url: file:///tmp/objects/\n")).unwrap();
}
}