1#![allow(clippy::disallowed_types)] #[cfg(feature = "iceberg-core")]
5mod identity;
6mod modes;
7mod registry;
8mod source;
9#[cfg(feature = "iceberg-core")]
10mod storage_diagnostics;
11mod table_definition;
12mod warehouse_location;
13
14use std::collections::HashMap;
15use std::fmt;
16use std::time::Duration;
17
18use crate::config::ConnectorConfig;
19use crate::connector::DeliveryGuarantee;
20use crate::error::ConnectorError;
21
22#[cfg(feature = "iceberg-core")]
23pub(crate) use identity::stable_catalog_identity;
24pub use modes::{
25 IcebergCatalogAuthType, IcebergCatalogType, IcebergReadBootstrap, IcebergReadMode,
26 IcebergSchemaEvolutionMode, IcebergStorageEncryption, IcebergStorageType,
27 IcebergWriteDistributionMode, IcebergWriteMode,
28};
29pub(crate) use registry::{sink_config_keys, source_config_keys};
30pub use source::IcebergSourceConfig;
31pub(crate) use table_definition::{
32 parse_parquet_compression, parse_table_fields, validate_distinct_names,
33 validate_persisted_properties, validate_table_definition,
34};
35pub use table_definition::{
36 IcebergNullOrder, IcebergPartitionField, IcebergSortDirection, IcebergSortField,
37 IcebergTransform,
38};
39#[cfg(feature = "iceberg-core")]
40pub(crate) use table_definition::{
41 PARQUET_COMPRESSION_PROPERTY, PARQUET_ROW_GROUP_SIZE_PROPERTY, TARGET_FILE_SIZE_PROPERTY,
42};
43
44const MIB: usize = 1024 * 1024;
45const MAX_ICEBERG_IO_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
46pub(crate) const ICEBERG_MAX_FILES_PER_CHECKPOINT: usize = 4_096;
47const MAX_CONFIG_COLLECTION_BYTES: usize = MIB;
48const MAX_CONFIG_PROPERTY_ENTRIES: usize = 256;
49
50pub(crate) fn validate_io_timeout(name: &str, value: Duration) -> Result<(), ConnectorError> {
51 if value.is_zero() {
52 return Err(ConnectorError::ConfigurationError(format!(
53 "{name} must be greater than zero"
54 )));
55 }
56 if value > MAX_ICEBERG_IO_TIMEOUT {
57 return Err(ConnectorError::ConfigurationError(format!(
58 "[LDB-ICEBERG-IO-TIMEOUT-LIMIT] {name} must not exceed {}s",
59 MAX_ICEBERG_IO_TIMEOUT.as_secs()
60 )));
61 }
62 Ok(())
63}
64
65#[derive(Clone)]
67pub struct IcebergCatalogConfig {
68 pub catalog_type: IcebergCatalogType,
70 pub catalog_uri: String,
72 pub warehouse: String,
74 pub prefix: Option<String>,
76 pub auth_type: IcebergCatalogAuthType,
78 pub oauth2_server_uri: Option<String>,
80 pub oauth2_client_id: Option<String>,
82 pub oauth2_scope: Option<String>,
84 pub access_delegation: bool,
86 pub connect_timeout: Duration,
88 pub request_timeout: Duration,
90 pub commit_timeout: Duration,
92 pub namespace: String,
94 pub table_name: String,
96 pub properties: HashMap<String, String>,
98}
99
100impl fmt::Debug for IcebergCatalogConfig {
101 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102 let mut property_keys: Vec<&str> = self.properties.keys().map(String::as_str).collect();
103 property_keys.sort_unstable();
104 formatter
105 .debug_struct("IcebergCatalogConfig")
106 .field("catalog_type", &self.catalog_type)
107 .field("catalog_uri", &"<configured>")
108 .field("warehouse", &"<configured>")
109 .field("prefix", &self.prefix)
110 .field("auth_type", &self.auth_type)
111 .field(
112 "oauth2_server_uri",
113 &self.oauth2_server_uri.as_ref().map(|_| "<configured>"),
114 )
115 .field(
116 "oauth2_client_id",
117 &self.oauth2_client_id.as_ref().map(|_| "<configured>"),
118 )
119 .field("oauth2_scope", &self.oauth2_scope)
120 .field("access_delegation", &self.access_delegation)
121 .field("connect_timeout", &self.connect_timeout)
122 .field("request_timeout", &self.request_timeout)
123 .field("commit_timeout", &self.commit_timeout)
124 .field("namespace", &self.namespace)
125 .field("table_name", &self.table_name)
126 .field("property_keys", &property_keys)
127 .finish()
128 }
129}
130
131fn parse_catalog_auth_type(
132 config: &ConnectorConfig,
133 properties: &HashMap<String, String>,
134) -> Result<IcebergCatalogAuthType, ConnectorError> {
135 let auth_type = match config.get("catalog.auth.type") {
136 Some(value) => value.parse().map_err(ConnectorError::ConfigurationError)?,
137 None if properties.contains_key("token") => IcebergCatalogAuthType::Bearer,
138 None if properties.contains_key("credential") => IcebergCatalogAuthType::OAuth2,
139 None => IcebergCatalogAuthType::None,
140 };
141 match auth_type {
142 IcebergCatalogAuthType::None
143 if properties.contains_key("token") || properties.contains_key("credential") =>
144 {
145 return Err(ConnectorError::ConfigurationError(
146 "catalog.auth.type=none cannot configure catalog.property.token or catalog.property.credential"
147 .into(),
148 ));
149 }
150 IcebergCatalogAuthType::Bearer if properties.get("token").is_none_or(String::is_empty) => {
151 return Err(ConnectorError::ConfigurationError(
152 "catalog.auth.type=bearer requires a non-empty catalog.property.token".into(),
153 ));
154 }
155 IcebergCatalogAuthType::Bearer if properties.contains_key("credential") => {
156 return Err(ConnectorError::ConfigurationError(
157 "catalog.auth.type=bearer cannot also configure catalog.property.credential".into(),
158 ));
159 }
160 IcebergCatalogAuthType::OAuth2
161 if properties.get("credential").is_none_or(String::is_empty) =>
162 {
163 return Err(ConnectorError::ConfigurationError(
164 "catalog.auth.type=oauth2 requires a non-empty resolved catalog.property.credential"
165 .into(),
166 ));
167 }
168 IcebergCatalogAuthType::OAuth2 if properties.contains_key("token") => {
169 return Err(ConnectorError::ConfigurationError(
170 "catalog.auth.type=oauth2 cannot also configure catalog.property.token".into(),
171 ));
172 }
173 IcebergCatalogAuthType::None
174 | IcebergCatalogAuthType::Bearer
175 | IcebergCatalogAuthType::OAuth2 => {}
176 }
177
178 if auth_type != IcebergCatalogAuthType::OAuth2 {
179 let typed_option = [
180 "catalog.oauth2.server_uri",
181 "catalog.oauth2.client_id",
182 "catalog.oauth2.scope",
183 ]
184 .into_iter()
185 .find(|key| {
186 config
187 .get(key)
188 .is_some_and(|value| !value.trim().is_empty())
189 });
190 let legacy_option = ["oauth2-server-uri", "scope", "audience", "resource"]
191 .into_iter()
192 .find(|key| properties.contains_key(*key));
193 if let Some(option) = typed_option {
194 return Err(ConnectorError::ConfigurationError(format!(
195 "{option} requires catalog.auth.type=oauth2"
196 )));
197 }
198 if let Some(option) = legacy_option {
199 return Err(ConnectorError::ConfigurationError(format!(
200 "catalog.property.{option} requires catalog.auth.type=oauth2"
201 )));
202 }
203 }
204
205 Ok(auth_type)
206}
207
208impl IcebergCatalogConfig {
209 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
215 let catalog_type = parse_or_default(config, "catalog.type")?;
216 let catalog_uri = config.require("catalog.uri")?.trim().to_string();
217 let warehouse = warehouse_location::canonicalize(required_alias(
218 config,
219 "catalog.warehouse",
220 "warehouse",
221 )?)?;
222 let namespace = config.require("namespace")?.trim().to_string();
223 let table_name = config.require("table.name")?.trim().to_string();
224 for (name, value) in [
225 ("catalog.uri", catalog_uri.as_str()),
226 ("catalog.warehouse", warehouse.as_str()),
227 ("namespace", namespace.as_str()),
228 ("table.name", table_name.as_str()),
229 ] {
230 if value.is_empty() {
231 return Err(ConnectorError::ConfigurationError(format!(
232 "{name} must not be empty"
233 )));
234 }
235 }
236 for (name, value) in [
237 ("catalog.uri", catalog_uri.as_str()),
238 ("catalog.warehouse", warehouse.as_str()),
239 ] {
240 if crate::security::value_contains_uri_secret(value, false) {
241 return Err(ConnectorError::ConfigurationError(format!(
242 "{name} must not embed credentials; use resolved catalog or storage secret options"
243 )));
244 }
245 }
246 let properties = config.properties_with_prefix("catalog.property.");
247 validate_property_map_bounds("catalog.property.*", &properties)?;
248 let auth_type = parse_catalog_auth_type(config, &properties)?;
249
250 let oauth2_server_uri = optional_non_empty(config, "catalog.oauth2.server_uri");
251 if oauth2_server_uri
252 .as_deref()
253 .is_some_and(|value| crate::security::value_contains_uri_secret(value, false))
254 {
255 return Err(ConnectorError::ConfigurationError(
256 "catalog.oauth2.server_uri must not embed credentials".into(),
257 ));
258 }
259
260 Ok(Self {
261 catalog_type,
262 catalog_uri,
263 warehouse,
264 prefix: optional_non_empty(config, "catalog.prefix"),
265 auth_type,
266 oauth2_server_uri,
267 oauth2_client_id: optional_non_empty(config, "catalog.oauth2.client_id"),
268 oauth2_scope: optional_non_empty(config, "catalog.oauth2.scope"),
269 access_delegation: parse_bool(config, "catalog.access_delegation", false)?,
270 connect_timeout: parse_duration(
271 config,
272 "catalog.connect_timeout",
273 Duration::from_secs(10),
274 )?,
275 request_timeout: parse_duration(
276 config,
277 "catalog.request_timeout",
278 Duration::from_secs(30),
279 )?,
280 commit_timeout: parse_duration(
281 config,
282 "catalog.commit_timeout",
283 Duration::from_secs(120),
284 )?,
285 namespace,
286 table_name,
287 properties,
288 })
289 }
290}
291
292#[derive(Clone)]
294pub struct IcebergStorageConfig {
295 pub storage_type: Option<IcebergStorageType>,
297 pub endpoint: Option<String>,
299 pub region: Option<String>,
301 pub path_style: bool,
303 pub request_timeout: Duration,
305 pub connect_timeout: Duration,
307 pub encryption: IcebergStorageEncryption,
309 pub kms_key: Option<String>,
311 pub properties: HashMap<String, String>,
313}
314
315impl fmt::Debug for IcebergStorageConfig {
316 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
317 let mut property_keys: Vec<&str> = self.properties.keys().map(String::as_str).collect();
318 property_keys.sort_unstable();
319 formatter
320 .debug_struct("IcebergStorageConfig")
321 .field("storage_type", &self.storage_type)
322 .field("endpoint", &self.endpoint.as_ref().map(|_| "<configured>"))
323 .field("region", &self.region)
324 .field("path_style", &self.path_style)
325 .field("request_timeout", &self.request_timeout)
326 .field("connect_timeout", &self.connect_timeout)
327 .field("encryption", &self.encryption)
328 .field("kms_key", &self.kms_key.as_ref().map(|_| "<configured>"))
329 .field("property_keys", &property_keys)
330 .finish()
331 }
332}
333
334impl IcebergStorageConfig {
335 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
341 let storage_type = config
342 .get("storage.type")
343 .map(str::parse)
344 .transpose()
345 .map_err(ConnectorError::ConfigurationError)?;
346 let encryption = parse_or_default(config, "storage.encryption")?;
347 let kms_key = optional_non_empty(config, "storage.kms_key");
348 if encryption == IcebergStorageEncryption::Kms && kms_key.is_none() {
349 return Err(ConnectorError::ConfigurationError(
350 "storage.kms_key is required when storage.encryption=kms".into(),
351 ));
352 }
353 if encryption != IcebergStorageEncryption::Kms && kms_key.is_some() {
354 return Err(ConnectorError::ConfigurationError(
355 "storage.kms_key requires storage.encryption=kms".into(),
356 ));
357 }
358 let path_style = parse_optional_bool(
359 "storage.path_style",
360 config
361 .get("storage.path_style")
362 .or_else(|| config.get("storage.property.s3.path-style-access"))
363 .or_else(|| config.get("catalog.property.s3.path-style-access")),
364 false,
365 )?;
366
367 let endpoint = optional_non_empty(config, "storage.endpoint");
368 if endpoint
369 .as_deref()
370 .is_some_and(|value| crate::security::value_contains_uri_secret(value, false))
371 {
372 return Err(ConnectorError::ConfigurationError(
373 "storage.endpoint must not embed credentials".into(),
374 ));
375 }
376 let properties = config.properties_with_prefix("storage.property.");
377 validate_property_map_bounds("storage.property.*", &properties)?;
378
379 Ok(Self {
380 storage_type,
381 endpoint,
382 region: optional_non_empty(config, "storage.region"),
383 path_style,
384 request_timeout: parse_duration(
385 config,
386 "storage.request_timeout",
387 Duration::from_secs(30),
388 )?,
389 connect_timeout: parse_duration(
390 config,
391 "storage.connect_timeout",
392 Duration::from_secs(10),
393 )?,
394 encryption,
395 kms_key,
396 properties,
397 })
398 }
399}
400
401#[derive(Clone)]
403pub struct IcebergSinkConfig {
404 pub catalog: IcebergCatalogConfig,
406 pub storage: IcebergStorageConfig,
408 pub write_mode: IcebergWriteMode,
410 pub compression: String,
412 pub target_file_size_bytes: usize,
414 pub parquet_row_group_size_bytes: usize,
416 pub max_buffer_rows: usize,
418 pub max_buffer_bytes: usize,
420 pub max_open_partitions: usize,
422 pub max_files_per_checkpoint: usize,
424 pub max_descriptor_bytes: usize,
426 pub max_flush_age: Duration,
428 pub distribution_mode: IcebergWriteDistributionMode,
430 pub identifier_fields: Vec<String>,
432 pub schema_evolution_mode: IcebergSchemaEvolutionMode,
434 pub delivery_guarantee: DeliveryGuarantee,
436 pub table_ref: String,
438 pub auto_create: bool,
440 pub format_version: u8,
442 pub partition_spec: Vec<IcebergPartitionField>,
444 pub sort_order: Vec<IcebergSortField>,
446 pub initial_table_properties: HashMap<String, String>,
448}
449
450impl fmt::Debug for IcebergSinkConfig {
451 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
452 formatter
453 .debug_struct("IcebergSinkConfig")
454 .field("catalog", &self.catalog)
455 .field("storage", &self.storage)
456 .field("write_mode", &self.write_mode)
457 .field("compression", &self.compression)
458 .field("target_file_size_bytes", &self.target_file_size_bytes)
459 .field(
460 "parquet_row_group_size_bytes",
461 &self.parquet_row_group_size_bytes,
462 )
463 .field("max_buffer_rows", &self.max_buffer_rows)
464 .field("max_buffer_bytes", &self.max_buffer_bytes)
465 .field("max_open_partitions", &self.max_open_partitions)
466 .field("max_files_per_checkpoint", &self.max_files_per_checkpoint)
467 .field("max_descriptor_bytes", &self.max_descriptor_bytes)
468 .field("max_flush_age", &self.max_flush_age)
469 .field("distribution_mode", &self.distribution_mode)
470 .field("identifier_fields", &self.identifier_fields)
471 .field("schema_evolution_mode", &self.schema_evolution_mode)
472 .field("delivery_guarantee", &self.delivery_guarantee)
473 .field("table_ref", &self.table_ref)
474 .field("auto_create", &self.auto_create)
475 .field("format_version", &self.format_version)
476 .field("partition_spec", &self.partition_spec)
477 .field("sort_order", &self.sort_order)
478 .field(
479 "initial_table_property_count",
480 &self.initial_table_properties.len(),
481 )
482 .finish()
483 }
484}
485
486impl IcebergSinkConfig {
487 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
493 let catalog = IcebergCatalogConfig::from_config(config)?;
494 let storage = IcebergStorageConfig::from_config(config)?;
495 let delivery_guarantee = config
496 .get("delivery.guarantee")
497 .unwrap_or("at-least-once")
498 .parse()
499 .map_err(ConnectorError::ConfigurationError)?;
500 let compression = parse_parquet_compression(
501 optional_alias(config, "parquet.compression", "compression")?.unwrap_or("zstd"),
502 )?;
503 let target_file_size_bytes = parse_nonzero_alias(
504 config,
505 "target.file.size.bytes",
506 "target.file.size",
507 128 * MIB,
508 )?;
509 let parquet_row_group_size_bytes =
510 parse_nonzero(config, "parquet.row.group.size.bytes", 128 * MIB)?;
511 let format_version = config.get_parsed("format.version")?.unwrap_or(2);
512 if !matches!(format_version, 1..=3) {
513 return Err(ConnectorError::ConfigurationError(
514 "format.version must be 1, 2, or 3".into(),
515 ));
516 }
517 let auto_create = parse_bool(config, "auto.create", false)?;
518 let partition_spec = parse_table_fields(config, "partition.spec")?;
519 let sort_order = parse_table_fields(config, "sort.order")?;
520 validate_table_definition(&partition_spec, &sort_order)?;
521 let identifier_fields =
522 parse_comma_list(config.get("identifier.fields"), "identifier.fields", 128)?;
523 validate_distinct_names(&identifier_fields, "identifier.fields")?;
524 let initial_table_properties = config.properties_with_prefix("table.property.");
525 validate_persisted_properties(&initial_table_properties)?;
526 if !auto_create
527 && (config.get("format.version").is_some()
528 || !partition_spec.is_empty()
529 || !sort_order.is_empty()
530 || !initial_table_properties.is_empty())
531 {
532 return Err(ConnectorError::ConfigurationError(
533 "format.version, partition.spec, sort.order, and table.property.* require auto.create=true"
534 .into(),
535 ));
536 }
537 if auto_create && format_version == 1 && !identifier_fields.is_empty() {
538 return Err(ConnectorError::ConfigurationError(
539 "identifier.fields require format.version=2 or 3 for an auto-created table".into(),
540 ));
541 }
542
543 let parsed = Self {
544 catalog,
545 storage,
546 write_mode: parse_or_default(config, "write.mode")?,
547 compression,
548 target_file_size_bytes,
549 parquet_row_group_size_bytes,
550 max_buffer_rows: parse_nonzero(config, "max.buffer.rows", 65_536)?,
551 max_buffer_bytes: parse_nonzero(config, "max.buffer.bytes", 64 * MIB)?,
552 max_open_partitions: parse_nonzero(config, "max.open.partitions", 64)?,
553 max_files_per_checkpoint: parse_nonzero(
554 config,
555 "max.files.per.checkpoint",
556 ICEBERG_MAX_FILES_PER_CHECKPOINT,
557 )?,
558 max_descriptor_bytes: parse_nonzero(
559 config,
560 "max.descriptor.bytes",
561 crate::connector::MAX_COORDINATED_COMMIT_PAYLOAD_BYTES,
562 )?,
563 max_flush_age: parse_duration(config, "max.flush.age", Duration::from_secs(300))?,
564 distribution_mode: parse_or_default(config, "write.distribution.mode")?,
565 identifier_fields,
566 schema_evolution_mode: parse_or_default(config, "schema.evolution.mode")?,
567 delivery_guarantee,
568 table_ref: config.get("table.ref").unwrap_or("main").trim().to_string(),
569 auto_create,
570 format_version,
571 partition_spec,
572 sort_order,
573 initial_table_properties,
574 };
575 parsed.validate_writer_limits()?;
576 Ok(parsed)
577 }
578
579 #[cfg(feature = "iceberg-core")]
580 pub(crate) fn validate_table_creation(&self) -> Result<(), ConnectorError> {
581 if !matches!(self.format_version, 1..=3) {
582 return Err(ConnectorError::ConfigurationError(
583 "format.version must be 1, 2, or 3".into(),
584 ));
585 }
586 parse_parquet_compression(&self.compression)?;
587 validate_distinct_names(&self.identifier_fields, "identifier.fields")?;
588 validate_table_definition(&self.partition_spec, &self.sort_order)?;
589 validate_persisted_properties(&self.initial_table_properties)?;
590 if self.format_version == 1 && !self.identifier_fields.is_empty() {
591 return Err(ConnectorError::ConfigurationError(
592 "identifier.fields require format.version=2 or 3 for an auto-created table".into(),
593 ));
594 }
595 Ok(())
596 }
597
598 pub(crate) fn validate_writer_limits(&self) -> Result<(), ConnectorError> {
599 validate_property_map_bounds("catalog.property.*", &self.catalog.properties)?;
600 validate_property_map_bounds("storage.property.*", &self.storage.properties)?;
601 for (name, value) in [
602 ("target.file.size.bytes", self.target_file_size_bytes),
603 (
604 "parquet.row.group.size.bytes",
605 self.parquet_row_group_size_bytes,
606 ),
607 ("max.buffer.rows", self.max_buffer_rows),
608 ("max.buffer.bytes", self.max_buffer_bytes),
609 ("max.open.partitions", self.max_open_partitions),
610 ("max.files.per.checkpoint", self.max_files_per_checkpoint),
611 ("max.descriptor.bytes", self.max_descriptor_bytes),
612 ] {
613 if value == 0 {
614 return Err(ConnectorError::ConfigurationError(format!(
615 "{name} must be greater than zero"
616 )));
617 }
618 }
619 if self.max_flush_age.is_zero() {
620 return Err(ConnectorError::ConfigurationError(
621 "max.flush.age must be greater than zero".into(),
622 ));
623 }
624 for (name, value) in [
625 ("catalog.connect_timeout", self.catalog.connect_timeout),
626 ("catalog.request_timeout", self.catalog.request_timeout),
627 ("catalog.commit_timeout", self.catalog.commit_timeout),
628 ("storage.request_timeout", self.storage.request_timeout),
629 ("storage.connect_timeout", self.storage.connect_timeout),
630 ] {
631 validate_io_timeout(name, value)?;
632 }
633 if self.max_files_per_checkpoint > ICEBERG_MAX_FILES_PER_CHECKPOINT {
634 return Err(ConnectorError::ConfigurationError(format!(
635 "max.files.per.checkpoint must not exceed {ICEBERG_MAX_FILES_PER_CHECKPOINT}"
636 )));
637 }
638 if self.max_open_partitions > self.max_files_per_checkpoint {
639 return Err(ConnectorError::ConfigurationError(
640 "max.open.partitions must not exceed max.files.per.checkpoint".into(),
641 ));
642 }
643 if self.max_descriptor_bytes > crate::connector::MAX_COORDINATED_COMMIT_PAYLOAD_BYTES {
644 return Err(ConnectorError::ConfigurationError(format!(
645 "max.descriptor.bytes must not exceed {}",
646 crate::connector::MAX_COORDINATED_COMMIT_PAYLOAD_BYTES
647 )));
648 }
649 parse_parquet_compression(&self.compression)?;
650 Ok(())
651 }
652}
653
654fn parse_or_default<T>(config: &ConnectorConfig, key: &str) -> Result<T, ConnectorError>
655where
656 T: Default + std::str::FromStr<Err = String>,
657{
658 config
659 .get(key)
660 .map(str::parse)
661 .transpose()
662 .map(Option::unwrap_or_default)
663 .map_err(ConnectorError::ConfigurationError)
664}
665
666fn parse_bool(config: &ConnectorConfig, key: &str, default: bool) -> Result<bool, ConnectorError> {
667 parse_optional_bool(key, config.get(key), default)
668}
669
670fn parse_optional_bool(
671 key: &str,
672 value: Option<&str>,
673 default: bool,
674) -> Result<bool, ConnectorError> {
675 match value {
676 None => Ok(default),
677 Some(value) if value.eq_ignore_ascii_case("true") => Ok(true),
678 Some(value) if value.eq_ignore_ascii_case("false") => Ok(false),
679 Some(value) => Err(ConnectorError::ConfigurationError(format!(
680 "invalid {key}: '{value}'; expected true or false"
681 ))),
682 }
683}
684
685fn parse_nonzero(
686 config: &ConnectorConfig,
687 key: &str,
688 default: usize,
689) -> Result<usize, ConnectorError> {
690 config
691 .get(key)
692 .map_or(Ok(default), |value| parse_nonzero_value(key, value))
693}
694
695fn parse_nonzero_alias(
696 config: &ConnectorConfig,
697 key: &str,
698 alias: &str,
699 default: usize,
700) -> Result<usize, ConnectorError> {
701 optional_alias(config, key, alias)?.map_or(Ok(default), |value| parse_nonzero_value(key, value))
702}
703
704fn parse_nonzero_value(key: &str, value: &str) -> Result<usize, ConnectorError> {
705 let parsed = value
706 .parse::<usize>()
707 .map_err(|_| ConnectorError::ConfigurationError(format!("invalid {key}: '{value}'")))?;
708 if parsed == 0 {
709 return Err(ConnectorError::ConfigurationError(format!(
710 "{key} must be greater than zero"
711 )));
712 }
713 Ok(parsed)
714}
715
716fn parse_duration(
717 config: &ConnectorConfig,
718 key: &str,
719 default: Duration,
720) -> Result<Duration, ConnectorError> {
721 config
722 .get(key)
723 .map_or(Ok(default), |value| parse_duration_value(key, value))
724}
725
726fn parse_duration_value(key: &str, value: &str) -> Result<Duration, ConnectorError> {
727 let value = value.trim();
728 let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") {
729 (number, 1_u64)
730 } else if let Some(number) = value.strip_suffix('s') {
731 (number, 1_000)
732 } else if let Some(number) = value.strip_suffix('m') {
733 (number, 60_000)
734 } else {
735 (value, 1)
736 };
737 let milliseconds = number.trim().parse::<u64>().map_err(|_| {
738 ConnectorError::ConfigurationError(format!(
739 "invalid {key}: '{value}'; expected an integer with ms, s, or m"
740 ))
741 })?;
742 if milliseconds == 0 {
743 return Err(ConnectorError::ConfigurationError(format!(
744 "{key} must be greater than zero"
745 )));
746 }
747 milliseconds
748 .checked_mul(multiplier)
749 .map(Duration::from_millis)
750 .ok_or_else(|| ConnectorError::ConfigurationError(format!("{key} is too large")))
751}
752
753fn optional_non_empty(config: &ConnectorConfig, key: &str) -> Option<String> {
754 config
755 .get(key)
756 .map(str::trim)
757 .filter(|value| !value.is_empty())
758 .map(str::to_string)
759}
760
761fn optional_alias<'a>(
762 config: &'a ConnectorConfig,
763 key: &str,
764 alias: &str,
765) -> Result<Option<&'a str>, ConnectorError> {
766 match (config.get(key), config.get(alias)) {
767 (Some(_), Some(_)) => Err(ConnectorError::ConfigurationError(format!(
768 "{key} and its legacy alias {alias} cannot both be set"
769 ))),
770 (value @ Some(_), None) | (None, value @ Some(_)) => Ok(value),
771 (None, None) => Ok(None),
772 }
773}
774
775fn required_alias<'a>(
776 config: &'a ConnectorConfig,
777 key: &str,
778 alias: &str,
779) -> Result<&'a str, ConnectorError> {
780 optional_alias(config, key, alias)?.ok_or_else(|| ConnectorError::missing_config(key))
781}
782
783fn parse_optional_alias<T>(
784 config: &ConnectorConfig,
785 key: &str,
786 alias: &str,
787) -> Result<Option<T>, ConnectorError>
788where
789 T: std::str::FromStr,
790 T::Err: fmt::Display,
791{
792 optional_alias(config, key, alias)?
793 .map(|value| {
794 value.parse().map_err(|error| {
795 ConnectorError::ConfigurationError(format!("invalid {key}: {error}"))
796 })
797 })
798 .transpose()
799}
800
801fn parse_comma_list(
802 value: Option<&str>,
803 key: &str,
804 max_entries: usize,
805) -> Result<Vec<String>, ConnectorError> {
806 let value = value.unwrap_or_default();
807 if value.len() > MAX_CONFIG_COLLECTION_BYTES {
808 return Err(ConnectorError::ConfigurationError(format!(
809 "{key} is {} bytes; the limit is {MAX_CONFIG_COLLECTION_BYTES}",
810 value.len()
811 )));
812 }
813 let values = value
814 .split(',')
815 .map(str::trim)
816 .filter(|item| !item.is_empty())
817 .map(str::to_string)
818 .take(max_entries.saturating_add(1))
819 .collect::<Vec<_>>();
820 if values.len() > max_entries {
821 return Err(ConnectorError::ConfigurationError(format!(
822 "{key} contains more than {max_entries} entries"
823 )));
824 }
825 Ok(values)
826}
827
828fn validate_property_map_bounds(
829 name: &str,
830 properties: &HashMap<String, String>,
831) -> Result<(), ConnectorError> {
832 if properties.len() > MAX_CONFIG_PROPERTY_ENTRIES {
833 return Err(ConnectorError::ConfigurationError(format!(
834 "[LDB-ICEBERG-CONFIG-PROPERTY-LIMIT] {name} contains {} entries; the limit is {MAX_CONFIG_PROPERTY_ENTRIES}",
835 properties.len()
836 )));
837 }
838 let bytes = properties.iter().try_fold(0_usize, |total, (key, value)| {
839 total.checked_add(key.len())?.checked_add(value.len())
840 });
841 if bytes.is_none_or(|bytes| bytes > MAX_CONFIG_COLLECTION_BYTES) {
842 return Err(ConnectorError::ConfigurationError(format!(
843 "[LDB-ICEBERG-CONFIG-PROPERTY-LIMIT] {name} exceeds the {MAX_CONFIG_COLLECTION_BYTES}-byte limit"
844 )));
845 }
846 if properties
847 .keys()
848 .any(|key| key.is_empty() || key.trim() != key)
849 {
850 return Err(ConnectorError::ConfigurationError(format!(
851 "{name} contains an empty or whitespace-padded property name"
852 )));
853 }
854 Ok(())
855}
856
857pub fn validate_sink_schema(
863 pipeline: &arrow_schema::Schema,
864 table: &arrow_schema::Schema,
865) -> Result<(), ConnectorError> {
866 for field in pipeline.fields() {
867 let table_field = table.field_with_name(field.name()).map_err(|_| {
868 ConnectorError::SchemaMismatch(format!(
869 "pipeline field '{}' ({}) not found in Iceberg table schema",
870 field.name(),
871 field.data_type()
872 ))
873 })?;
874 if field.data_type() != table_field.data_type()
875 && !is_safe_iceberg_widening(field.data_type(), table_field.data_type())
876 {
877 return Err(ConnectorError::SchemaMismatch(format!(
878 "field '{}': pipeline type {} incompatible with table type {}",
879 field.name(),
880 field.data_type(),
881 table_field.data_type()
882 )));
883 }
884 }
885 Ok(())
886}
887
888pub(crate) fn is_safe_iceberg_widening(
889 from: &arrow_schema::DataType,
890 to: &arrow_schema::DataType,
891) -> bool {
892 use arrow_schema::DataType;
893 matches!(
894 (from, to),
895 (
896 DataType::Int8,
897 DataType::Int16 | DataType::Int32 | DataType::Int64
898 ) | (DataType::Int16, DataType::Int32 | DataType::Int64)
899 | (DataType::Int32, DataType::Int64)
900 | (DataType::Float32, DataType::Float64)
901 | (DataType::Utf8, DataType::LargeUtf8)
902 )
903}
904
905#[cfg(test)]
906mod tests;