1use bytesize::ByteSize;
17use serde::{Deserialize, Serialize};
18use spate_core::config::{ComponentConfig, ConfigError};
19use std::collections::BTreeMap;
20use url::Url;
21
22const DENYLIST: &[(&str, &str)] = &[
33 ("bucket", "owned by the typed `url` field"),
34 ("bucket_name", "owned by the typed `url` field"),
35 ("aws_bucket", "owned by the typed `url` field"),
36 ("aws_bucket_name", "owned by the typed `url` field"),
37];
38
39fn default_prefetch_bytes() -> ByteSize {
40 ByteSize::mib(8)
41}
42
43fn default_chunk_bytes() -> ByteSize {
44 ByteSize::kib(512)
45}
46
47fn default_split_target_bytes() -> ByteSize {
48 ByteSize::mib(64)
49}
50
51const SPLIT_TARGET_FLOOR: u64 = 1024 * 1024;
55
56struct Redacted<'a>(&'a BTreeMap<String, String>);
60
61impl std::fmt::Debug for Redacted<'_> {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_map()
64 .entries(self.0.keys().map(|k| (k, "<redacted>")))
65 .finish()
66 }
67}
68
69#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "kebab-case")]
72#[non_exhaustive]
73pub enum Compression {
74 #[default]
77 Auto,
78 None,
80 Gzip,
82 Zstd,
84}
85
86#[derive(Clone, Deserialize, PartialEq)]
89#[serde(deny_unknown_fields)]
90#[non_exhaustive]
91pub struct S3SourceConfig {
92 pub url: String,
97 #[serde(default)]
99 pub compression: Compression,
100 #[serde(default = "default_split_target_bytes")]
108 pub split_target_bytes: ByteSize,
109 #[serde(default)]
114 pub refresh_listing: bool,
115 #[serde(default = "default_prefetch_bytes")]
130 pub prefetch_bytes: ByteSize,
131 #[serde(default = "default_chunk_bytes")]
133 pub chunk_bytes: ByteSize,
134 #[serde(default)]
138 pub store: BTreeMap<String, String>,
139}
140
141impl std::fmt::Debug for S3SourceConfig {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.debug_struct("S3SourceConfig")
146 .field("url", &self.url)
147 .field("compression", &self.compression)
148 .field("split_target_bytes", &self.split_target_bytes)
149 .field("refresh_listing", &self.refresh_listing)
150 .field("prefetch_bytes", &self.prefetch_bytes)
151 .field("chunk_bytes", &self.chunk_bytes)
152 .field("store", &Redacted(&self.store))
153 .finish()
154 }
155}
156
157impl S3SourceConfig {
158 pub fn from_component_config(section: &ComponentConfig) -> Result<Self, ConfigError> {
161 let cfg: S3SourceConfig = section.deserialize_into()?;
162 cfg.validate()?;
163 Ok(cfg)
164 }
165
166 pub fn validate(&self) -> Result<(), ConfigError> {
168 parse_url("source.s3.url", &self.url)?;
169 if self.chunk_bytes.as_u64() == 0 {
170 return Err(ConfigError::Validation(
171 "source.s3.chunk_bytes must not be zero".into(),
172 ));
173 }
174 if self.prefetch_bytes.as_u64() < self.chunk_bytes.as_u64() {
175 return Err(ConfigError::Validation(format!(
176 "source.s3.prefetch_bytes ({}) must be at least chunk_bytes ({})",
177 self.prefetch_bytes, self.chunk_bytes
178 )));
179 }
180 if self.split_target_bytes.as_u64() < SPLIT_TARGET_FLOOR {
181 return Err(ConfigError::Validation(format!(
182 "source.s3.split_target_bytes ({}) must be at least 1MiB",
183 self.split_target_bytes
184 )));
185 }
186 for key in self.store.keys() {
189 let lowered = key.to_ascii_lowercase();
190 if let Some((_, why)) = DENYLIST.iter().find(|(denied, _)| *denied == lowered) {
191 return Err(ConfigError::Validation(format!(
192 "source.s3.store.\"{key}\" cannot be overridden: {why}"
193 )));
194 }
195 }
196 Ok(())
197 }
198}
199
200fn parse_url(field: &str, value: &str) -> Result<Url, ConfigError> {
202 if value.trim().is_empty() {
203 return Err(ConfigError::Validation(format!(
204 "{field} must not be empty"
205 )));
206 }
207 let url = Url::parse(value)
208 .map_err(|e| ConfigError::Validation(format!("{field} is not a valid URL: {e}")))?;
209 if url.scheme() == "memory" {
212 return Err(ConfigError::Validation(format!(
213 "{field}: memory:// creates a new empty store per component and cannot be \
214 shared; use file:// for infrastructure-free runs"
215 )));
216 }
217 Ok(url)
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn section(body: &str) -> ComponentConfig {
225 let yaml = format!("s3:\n{body}");
226 let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
227 ComponentConfig::new("s3", value["s3"].clone())
228 }
229
230 fn minimal() -> String {
231 " url: s3://bucket/exports/\n".to_string()
232 }
233
234 #[test]
235 fn minimal_config_gets_documented_defaults() {
236 let cfg = S3SourceConfig::from_component_config(§ion(&minimal())).unwrap();
237 assert_eq!(cfg.compression, Compression::Auto);
238 assert_eq!(cfg.prefetch_bytes, ByteSize::mib(8));
239 assert_eq!(cfg.chunk_bytes, ByteSize::kib(512));
240 assert_eq!(cfg.split_target_bytes, ByteSize::mib(64));
241 assert!(!cfg.refresh_listing);
242 assert!(cfg.store.is_empty());
243 }
244
245 #[test]
246 fn planner_knobs_parse_and_floor() {
247 let body = format!(
248 "{} split_target_bytes: 32MB\n refresh_listing: true\n",
249 minimal()
250 );
251 let cfg = S3SourceConfig::from_component_config(§ion(&body)).unwrap();
252 assert_eq!(cfg.split_target_bytes, ByteSize::mb(32));
253 assert!(cfg.refresh_listing);
254
255 let low = format!("{} split_target_bytes: 64KB\n", minimal());
256 let err = S3SourceConfig::from_component_config(§ion(&low)).unwrap_err();
257 assert!(err.to_string().contains("split_target_bytes"), "{err}");
258 }
259
260 #[test]
261 fn removed_knobs_are_rejected() {
262 for extra in [
267 " lanes: 4\n",
268 " checkpoint:\n url: s3://bucket/_etl/b.json\n",
269 " coordination:\n nats:\n servers: [\"nats://n:4222\"]\n",
270 ] {
271 let body = format!("{}{extra}", minimal());
272 assert!(
273 S3SourceConfig::from_component_config(§ion(&body)).is_err(),
274 "must reject removed knob: {extra}"
275 );
276 }
277 }
278
279 #[test]
280 fn debug_never_prints_store_values() {
281 let body = format!(
282 "{} store:\n aws_secret_access_key: hunter2\n",
283 minimal()
284 );
285 let cfg = S3SourceConfig::from_component_config(§ion(&body)).unwrap();
286 let printed = format!("{cfg:?}");
287 assert!(!printed.contains("hunter2"), "{printed}");
288 assert!(
289 printed.contains("aws_secret_access_key") && printed.contains("<redacted>"),
290 "keys stay visible for debugging: {printed}"
291 );
292 }
293
294 #[test]
295 fn bucket_aliases_are_rejected_in_the_passthrough() {
296 for key in [
299 "bucket",
300 "bucket_name",
301 "aws_bucket",
302 "aws_bucket_name",
303 "Bucket",
304 "AWS_BUCKET_NAME",
305 ] {
306 let body = format!("{} store:\n {key}: other\n", minimal());
307 let err = S3SourceConfig::from_component_config(§ion(&body)).unwrap_err();
308 assert!(err.to_string().contains(key), "error names the key: {err}");
309 }
310 }
311
312 #[test]
313 fn memory_scheme_is_rejected_with_guidance() {
314 let err = S3SourceConfig::from_component_config(§ion(" url: memory:///data/\n"))
315 .unwrap_err();
316 assert!(err.to_string().contains("file://"), "actionable: {err}");
317 }
318
319 #[test]
320 fn bad_buffer_sizes_are_rejected() {
321 for extra in [
322 " chunk_bytes: 0\n",
323 " prefetch_bytes: 4KiB\n chunk_bytes: 1MiB\n",
324 ] {
325 let body = format!("{}{extra}", minimal());
326 assert!(
327 S3SourceConfig::from_component_config(§ion(&body)).is_err(),
328 "must reject: {extra}"
329 );
330 }
331 }
332
333 #[test]
334 fn unknown_fields_and_unknown_variants_are_rejected() {
335 for extra in [" prefix: also-here\n", " compression: xz\n"] {
336 let body = format!("{}{extra}", minimal());
337 assert!(
338 S3SourceConfig::from_component_config(§ion(&body)).is_err(),
339 "must reject: {extra}"
340 );
341 }
342 }
343
344 #[test]
345 fn file_urls_are_accepted() {
346 S3SourceConfig::from_component_config(§ion(" url: file:///tmp/objects/\n")).unwrap();
347 }
348}