Skip to main content

laminar_connectors/storage/
validation.rs

1//! Per-cloud-provider configuration validation.
2//!
3//! [`CloudConfigValidator`] checks [`ResolvedStorageOptions`] for missing
4//! or invalid credentials at connector `open()` time, producing clear,
5//! actionable error messages that include both the config key name and
6//! the fallback environment variable.
7
8use super::provider::StorageProvider;
9use super::resolver::ResolvedStorageOptions;
10
11/// Result of cloud configuration validation.
12#[derive(Debug, Clone)]
13pub struct CloudValidationResult {
14    /// Hard errors that prevent the connector from opening.
15    pub errors: Vec<CloudValidationError>,
16    /// Soft warnings (may still work with instance metadata / default creds).
17    pub warnings: Vec<CloudValidationWarning>,
18}
19
20impl CloudValidationResult {
21    /// Returns true if there are no hard errors.
22    #[must_use]
23    pub fn is_valid(&self) -> bool {
24        self.errors.is_empty()
25    }
26
27    /// Formats all errors into a single string for `ConnectorError`.
28    #[must_use]
29    pub fn error_message(&self) -> String {
30        self.errors
31            .iter()
32            .map(|e| e.message.as_str())
33            .collect::<Vec<_>>()
34            .join("; ")
35    }
36}
37
38/// A hard validation error.
39#[derive(Debug, Clone)]
40pub struct CloudValidationError {
41    /// The missing or invalid configuration key.
42    pub key: String,
43    /// The fallback environment variable (if applicable).
44    pub env_var: Option<String>,
45    /// Human-readable error message.
46    pub message: String,
47}
48
49/// A soft validation warning.
50#[derive(Debug, Clone)]
51pub struct CloudValidationWarning {
52    /// The configuration key this warning relates to.
53    pub key: String,
54    /// Human-readable warning message.
55    pub message: String,
56}
57
58/// Validates resolved storage options for a given provider.
59pub struct CloudConfigValidator;
60
61impl CloudConfigValidator {
62    /// Validates the resolved storage options for the detected provider.
63    ///
64    /// Returns a [`CloudValidationResult`] with any errors or warnings.
65    /// Hard errors indicate the connector cannot open. Warnings indicate
66    /// missing credentials that may still be resolved by instance metadata
67    /// or default credential providers.
68    #[must_use]
69    pub fn validate(resolved: &ResolvedStorageOptions) -> CloudValidationResult {
70        match resolved.provider {
71            StorageProvider::AwsS3 => Self::validate_s3(resolved),
72            StorageProvider::AzureAdls => Self::validate_azure(resolved),
73            StorageProvider::Gcs => Self::validate_gcs(resolved),
74            StorageProvider::Local => CloudValidationResult {
75                errors: Vec::new(),
76                warnings: Vec::new(),
77            },
78        }
79    }
80
81    fn validate_s3(resolved: &ResolvedStorageOptions) -> CloudValidationResult {
82        let mut errors = Vec::new();
83        let mut warnings = Vec::new();
84
85        // Region is required (no instance metadata fallback for region in object_store).
86        if !resolved.options.contains_key("aws_region") {
87            errors.push(CloudValidationError {
88                key: "aws_region".into(),
89                env_var: Some("AWS_REGION".into()),
90                message: "S3 paths require 'storage.aws_region' in config \
91                          or AWS_REGION environment variable"
92                    .into(),
93            });
94        }
95
96        // If access key is provided, secret key must also be provided.
97        if resolved.options.contains_key("aws_access_key_id")
98            && !resolved.options.contains_key("aws_secret_access_key")
99        {
100            errors.push(CloudValidationError {
101                key: "aws_secret_access_key".into(),
102                env_var: Some("AWS_SECRET_ACCESS_KEY".into()),
103                message: "'storage.aws_access_key_id' provided without \
104                          'storage.aws_secret_access_key'"
105                    .into(),
106            });
107        }
108
109        // If secret key is provided, access key must also be provided.
110        if resolved.options.contains_key("aws_secret_access_key")
111            && !resolved.options.contains_key("aws_access_key_id")
112        {
113            errors.push(CloudValidationError {
114                key: "aws_access_key_id".into(),
115                env_var: Some("AWS_ACCESS_KEY_ID".into()),
116                message: "'storage.aws_secret_access_key' provided without \
117                          'storage.aws_access_key_id'"
118                    .into(),
119            });
120        }
121
122        // Warn if no credentials at all (may use IAM role / instance profile).
123        if !resolved.has_credentials() {
124            warnings.push(CloudValidationWarning {
125                key: "aws_access_key_id".into(),
126                message: "No AWS credentials found in config or environment. \
127                          Will fall back to instance metadata (IAM role). \
128                          Set 'storage.aws_access_key_id' / \
129                          'storage.aws_secret_access_key' or AWS_ACCESS_KEY_ID / \
130                          AWS_SECRET_ACCESS_KEY if needed."
131                    .into(),
132            });
133        }
134
135        CloudValidationResult { errors, warnings }
136    }
137
138    fn validate_azure(resolved: &ResolvedStorageOptions) -> CloudValidationResult {
139        let mut errors = Vec::new();
140        let mut warnings = Vec::new();
141
142        // Account name is always required for Azure.
143        if !resolved.options.contains_key("azure_storage_account_name") {
144            errors.push(CloudValidationError {
145                key: "azure_storage_account_name".into(),
146                env_var: Some("AZURE_STORAGE_ACCOUNT_NAME".into()),
147                message: "Azure paths require 'storage.azure_storage_account_name' \
148                          in config or AZURE_STORAGE_ACCOUNT_NAME environment variable"
149                    .into(),
150            });
151        }
152
153        // Warn if no credentials (may use Managed Identity).
154        if !resolved.has_credentials() {
155            warnings.push(CloudValidationWarning {
156                key: "azure_storage_account_key".into(),
157                message: "No Azure credentials found in config or environment. \
158                          Will fall back to Managed Identity. \
159                          Set 'storage.azure_storage_account_key' / \
160                          AZURE_STORAGE_ACCOUNT_KEY, or \
161                          'storage.azure_storage_sas_token' / \
162                          AZURE_STORAGE_SAS_TOKEN if needed."
163                    .into(),
164            });
165        }
166
167        CloudValidationResult { errors, warnings }
168    }
169
170    fn validate_gcs(resolved: &ResolvedStorageOptions) -> CloudValidationResult {
171        let mut warnings = Vec::new();
172
173        // GCS credentials are warning-only (Application Default Credentials / Workload Identity).
174        if !resolved.has_credentials() {
175            warnings.push(CloudValidationWarning {
176                key: "google_service_account_path".into(),
177                message: "No GCS credentials found in config or environment. \
178                          Will fall back to Application Default Credentials / \
179                          Workload Identity. Set \
180                          'storage.google_service_account_path' / \
181                          GOOGLE_APPLICATION_CREDENTIALS if needed."
182                    .into(),
183            });
184        }
185
186        CloudValidationResult {
187            errors: Vec::new(),
188            warnings,
189        }
190    }
191}
192
193#[cfg(test)]
194#[allow(clippy::disallowed_types)] // cold path: storage configuration
195mod tests {
196    use std::collections::HashMap;
197
198    use super::*;
199
200    fn make_resolved(provider: StorageProvider, keys: &[(&str, &str)]) -> ResolvedStorageOptions {
201        let mut options = HashMap::new();
202        for (k, v) in keys {
203            options.insert((*k).to_string(), (*v).to_string());
204        }
205        ResolvedStorageOptions {
206            provider,
207            options,
208            env_resolved_keys: Vec::new(),
209        }
210    }
211
212    // ── S3 validation ──
213
214    #[test]
215    fn test_validate_s3_all_present() {
216        let resolved = make_resolved(
217            StorageProvider::AwsS3,
218            &[
219                ("aws_access_key_id", "AKID"),
220                ("aws_secret_access_key", "SECRET"),
221                ("aws_region", "us-east-1"),
222            ],
223        );
224        let result = CloudConfigValidator::validate(&resolved);
225        assert!(result.is_valid());
226        assert!(result.warnings.is_empty());
227    }
228
229    #[test]
230    fn test_validate_s3_missing_region() {
231        let resolved = make_resolved(
232            StorageProvider::AwsS3,
233            &[
234                ("aws_access_key_id", "AKID"),
235                ("aws_secret_access_key", "SECRET"),
236            ],
237        );
238        let result = CloudConfigValidator::validate(&resolved);
239        assert!(!result.is_valid());
240        assert!(result.errors.iter().any(|e| e.key == "aws_region"));
241        assert_eq!(result.errors[0].env_var.as_deref(), Some("AWS_REGION"));
242    }
243
244    #[test]
245    fn test_validate_s3_missing_credentials_warns() {
246        let resolved = make_resolved(StorageProvider::AwsS3, &[("aws_region", "us-east-1")]);
247        let result = CloudConfigValidator::validate(&resolved);
248        assert!(result.is_valid()); // Warning, not error.
249        assert!(!result.warnings.is_empty());
250    }
251
252    #[test]
253    fn test_validate_s3_access_key_without_secret() {
254        let resolved = make_resolved(
255            StorageProvider::AwsS3,
256            &[("aws_access_key_id", "AKID"), ("aws_region", "us-east-1")],
257        );
258        let result = CloudConfigValidator::validate(&resolved);
259        assert!(!result.is_valid());
260        assert!(result
261            .errors
262            .iter()
263            .any(|e| e.key == "aws_secret_access_key"));
264    }
265
266    #[test]
267    fn test_validate_s3_secret_key_without_access() {
268        let resolved = make_resolved(
269            StorageProvider::AwsS3,
270            &[
271                ("aws_secret_access_key", "SECRET"),
272                ("aws_region", "us-east-1"),
273            ],
274        );
275        let result = CloudConfigValidator::validate(&resolved);
276        assert!(!result.is_valid());
277        assert!(result.errors.iter().any(|e| e.key == "aws_access_key_id"));
278    }
279
280    #[test]
281    fn test_validate_s3_profile_sufficient() {
282        let resolved = make_resolved(
283            StorageProvider::AwsS3,
284            &[("aws_profile", "production"), ("aws_region", "us-east-1")],
285        );
286        let result = CloudConfigValidator::validate(&resolved);
287        assert!(result.is_valid());
288        assert!(result.warnings.is_empty()); // profile counts as credentials
289    }
290
291    // ── Azure validation ──
292
293    #[test]
294    fn test_validate_azure_all_present() {
295        let resolved = make_resolved(
296            StorageProvider::AzureAdls,
297            &[
298                ("azure_storage_account_name", "myaccount"),
299                ("azure_storage_account_key", "base64key=="),
300            ],
301        );
302        let result = CloudConfigValidator::validate(&resolved);
303        assert!(result.is_valid());
304        assert!(result.warnings.is_empty());
305    }
306
307    #[test]
308    fn test_validate_azure_missing_account_name() {
309        let resolved = make_resolved(
310            StorageProvider::AzureAdls,
311            &[("azure_storage_account_key", "base64key==")],
312        );
313        let result = CloudConfigValidator::validate(&resolved);
314        assert!(!result.is_valid());
315        assert!(result
316            .errors
317            .iter()
318            .any(|e| e.key == "azure_storage_account_name"));
319    }
320
321    #[test]
322    fn test_validate_azure_sas_token_sufficient() {
323        let resolved = make_resolved(
324            StorageProvider::AzureAdls,
325            &[
326                ("azure_storage_account_name", "myaccount"),
327                ("azure_storage_sas_token", "sv=2021-06&sig=abc"),
328            ],
329        );
330        let result = CloudConfigValidator::validate(&resolved);
331        assert!(result.is_valid());
332        assert!(result.warnings.is_empty());
333    }
334
335    #[test]
336    fn test_validate_azure_client_id_sufficient() {
337        let resolved = make_resolved(
338            StorageProvider::AzureAdls,
339            &[
340                ("azure_storage_account_name", "myaccount"),
341                ("azure_storage_client_id", "client-123"),
342            ],
343        );
344        let result = CloudConfigValidator::validate(&resolved);
345        assert!(result.is_valid());
346    }
347
348    #[test]
349    fn test_validate_azure_missing_credentials_warns() {
350        let resolved = make_resolved(
351            StorageProvider::AzureAdls,
352            &[("azure_storage_account_name", "myaccount")],
353        );
354        let result = CloudConfigValidator::validate(&resolved);
355        assert!(result.is_valid());
356        assert!(!result.warnings.is_empty());
357    }
358
359    // ── GCS validation ──
360
361    #[test]
362    fn test_validate_gcs_all_present() {
363        let resolved = make_resolved(
364            StorageProvider::Gcs,
365            &[("google_service_account_path", "/path/to/creds.json")],
366        );
367        let result = CloudConfigValidator::validate(&resolved);
368        assert!(result.is_valid());
369        assert!(result.warnings.is_empty());
370    }
371
372    #[test]
373    fn test_validate_gcs_inline_key_sufficient() {
374        let resolved = make_resolved(
375            StorageProvider::Gcs,
376            &[(
377                "google_service_account_key",
378                "{\"type\":\"service_account\"}",
379            )],
380        );
381        let result = CloudConfigValidator::validate(&resolved);
382        assert!(result.is_valid());
383        assert!(result.warnings.is_empty());
384    }
385
386    #[test]
387    fn test_validate_gcs_missing_credentials_warns() {
388        let resolved = make_resolved(StorageProvider::Gcs, &[]);
389        let result = CloudConfigValidator::validate(&resolved);
390        assert!(result.is_valid()); // Warning only.
391        assert!(!result.warnings.is_empty());
392    }
393
394    // ── Local validation ──
395
396    #[test]
397    fn test_validate_local_always_valid() {
398        let resolved = make_resolved(StorageProvider::Local, &[]);
399        let result = CloudConfigValidator::validate(&resolved);
400        assert!(result.is_valid());
401        assert!(result.warnings.is_empty());
402    }
403
404    // ── Utility tests ──
405
406    #[test]
407    fn test_error_message_formatting() {
408        let resolved = make_resolved(StorageProvider::AwsS3, &[]);
409        let result = CloudConfigValidator::validate(&resolved);
410        assert!(!result.is_valid());
411        let msg = result.error_message();
412        assert!(msg.contains("aws_region"));
413    }
414
415    #[test]
416    fn test_error_includes_env_var_hint() {
417        let resolved = make_resolved(StorageProvider::AwsS3, &[]);
418        let result = CloudConfigValidator::validate(&resolved);
419        let region_err = result
420            .errors
421            .iter()
422            .find(|e| e.key == "aws_region")
423            .unwrap();
424        assert_eq!(region_err.env_var.as_deref(), Some("AWS_REGION"));
425        assert!(region_err.message.contains("AWS_REGION"));
426    }
427}