Skip to main content

vector/sinks/azure_blob/
config.rs

1#![expect(
2    clippy::let_underscore_must_use,
3    reason = "derivative's Debug derive with format_with expands to a must_use let binding"
4)]
5
6use std::collections::{BTreeMap, HashMap};
7use std::fmt;
8use std::fs::File;
9use std::io::Read;
10use std::sync::Arc;
11
12use azure_core::{
13    Error,
14    credentials::TokenCredential,
15    error::ErrorKind,
16    http::{StatusCode, Url},
17};
18use azure_storage_blob::{BlobContainerClient, BlobContainerClientOptions};
19
20use bytes::Bytes;
21use derivative::Derivative;
22use futures::FutureExt;
23use snafu::Snafu;
24use tower::ServiceBuilder;
25use vector_lib::{
26    codecs::{JsonSerializerConfig, NewlineDelimitedEncoderConfig, encoding::Framer},
27    configurable::configurable_component,
28    request_metadata::{GroupedCountByteSize, MetaDescriptive, RequestMetadata},
29    sensitive_string::SensitiveString,
30    stream::{BatcherSettings, DriverResponse},
31};
32
33use super::request_builder::AzureBlobRequestOptions;
34use crate::{
35    codecs::{Encoder, EncodingConfigWithFraming, SinkType},
36    config::{
37        AcknowledgementsConfig, DataType, DynValidatedSink, GenerateConfig, Input, SinkConfig,
38        SinkContext, ValidatedSink,
39    },
40    event::{EventFinalizers, EventStatus, Finalizable},
41    sinks::{
42        Healthcheck, VectorSink,
43        azure_blob::{service::AzureBlobService, sink::AzureBlobSink},
44        azure_common::{
45            config::AzureAuthentication,
46            config::AzureBlobTlsConfig,
47            connection_string::{Auth, ParsedConnectionString},
48            shared_key_policy::SharedKeyAuthorizationPolicy,
49        },
50        util::{
51            BatchConfig, BulkSizeBasedDefaultBatchSettings, Compression, ServiceBuilderExt,
52            TowerRequestConfig, partitioner::KeyPartitioner, retries::RetryLogic,
53            service::TowerRequestConfigDefaults,
54        },
55    },
56    template::{ConfinedTemplate, ConfinementConfig, Template},
57};
58
59#[derive(Clone, Copy, Debug)]
60pub struct AzureBlobTowerRequestConfigDefaults;
61
62impl TowerRequestConfigDefaults for AzureBlobTowerRequestConfigDefaults {
63    const RATE_LIMIT_NUM: u64 = 250;
64}
65
66/// Configuration for the `azure_blob` sink.
67#[configurable_component(sink(
68    "azure_blob",
69    "Store your observability data in Azure Blob Storage."
70))]
71#[derive(Clone, Debug)]
72#[serde(deny_unknown_fields)]
73pub struct AzureBlobSinkConfig {
74    #[configurable(derived)]
75    #[serde(default)]
76    pub auth: Option<AzureAuthentication>,
77
78    /// The Azure Blob Storage Account connection string.
79    ///
80    /// Authentication with an access key or shared access signature (SAS)
81    /// are supported authentication methods. If using a non-account SAS,
82    /// healthchecks will fail and will need to be disabled by setting
83    /// `healthcheck.enabled` to `false` for this sink
84    ///
85    /// When generating an account SAS, the following are the minimum required option
86    /// settings for Vector to access blob storage and pass a health check.
87    /// | Option                 | Value              |
88    /// | ---------------------- | ------------------ |
89    /// | Allowed services       | Blob               |
90    /// | Allowed resource types | Container & Object |
91    /// | Allowed permissions    | Read & Create      |
92    ///
93    /// If you also configure the `tags` option, the SAS must include the
94    /// `Tags` permission. Azure applies the *Set Blob Tags* authorization requirement to
95    /// the `Put Blob` request that carries the `x-ms-tags` header, so without it tagged
96    /// uploads fail with an authorization error even when the health check still passes.
97    #[configurable(metadata(
98        docs::warnings = "Access keys and SAS tokens can be used to gain unauthorized access to Azure Blob Storage \
99        resources. Numerous security breaches have occurred due to leaked connection strings. It is important to keep \
100        connection strings secure and not expose them in logs, error messages, or version control systems."
101    ))]
102    #[configurable(metadata(
103        docs::examples = "DefaultEndpointsProtocol=https;AccountName=mylogstorage;AccountKey=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=;EndpointSuffix=core.windows.net"
104    ))]
105    #[configurable(metadata(
106        docs::examples = "BlobEndpoint=https://mylogstorage.blob.core.windows.net/;SharedAccessSignature=generatedsastoken"
107    ))]
108    #[configurable(metadata(docs::examples = "AccountName=mylogstorage"))]
109    #[configurable(required_one_of = "azure_blob_credentials")]
110    pub connection_string: Option<SensitiveString>,
111
112    /// The Azure Blob Storage Account name.
113    ///
114    /// If provided, this will be used instead of the `connection_string`.
115    /// This is useful for authenticating with an Azure credential.
116    #[configurable(metadata(docs::examples = "mylogstorage"))]
117    #[configurable(required_one_of = "azure_blob_credentials")]
118    pub(super) account_name: Option<String>,
119
120    /// The Azure Blob Storage endpoint.
121    ///
122    /// If provided, this will be used instead of the `connection_string`.
123    /// This is useful for authenticating with an Azure credential.
124    #[configurable(metadata(docs::examples = "https://mylogstorage.blob.core.windows.net/"))]
125    #[configurable(required_one_of = "azure_blob_credentials")]
126    pub(super) blob_endpoint: Option<String>,
127
128    /// The Azure Blob Storage Account container name.
129    #[configurable(metadata(docs::examples = "my-logs"))]
130    pub(super) container_name: String,
131
132    /// A prefix to apply to all blob keys.
133    ///
134    /// Prefixes are useful for partitioning objects, such as by creating a blob key that
135    /// stores blobs under a particular directory. If using a prefix for this purpose, it must end
136    /// in `/` to act as a directory path. A trailing `/` is **not** automatically added.
137    #[configurable(metadata(docs::examples = "date/%F/hour/%H/"))]
138    #[configurable(metadata(docs::examples = "year=%Y/month=%m/day=%d/"))]
139    #[configurable(metadata(
140        docs::examples = "kubernetes/{{ metadata.cluster }}/{{ metadata.application_name }}/"
141    ))]
142    #[serde(default = "default_blob_prefix")]
143    pub blob_prefix: Template,
144
145    /// The timestamp format for the time component of the blob key.
146    ///
147    /// By default, blob keys are appended with a timestamp that reflects when the blob are sent to
148    /// Azure Blob Storage, such that the resulting blob key is functionally equivalent to joining
149    /// the blob prefix with the formatted timestamp, such as `date=2022-07-18/1658176486`.
150    ///
151    /// This would represent a `blob_prefix` set to `date=%F/` and the timestamp of Mon Jul 18 2022
152    /// 20:34:44 GMT+0000, with the `filename_time_format` being set to `%s`, which renders
153    /// timestamps in seconds since the Unix epoch.
154    ///
155    /// Supports the common [`strftime`][chrono_strftime_specifiers] specifiers found in most
156    /// languages.
157    ///
158    /// When set to an empty string, no timestamp is appended to the blob prefix.
159    ///
160    /// [chrono_strftime_specifiers]: https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers
161    #[configurable(metadata(docs::syntax_override = "strftime"))]
162    pub blob_time_format: Option<String>,
163
164    /// Whether or not to append a UUID v4 token to the end of the blob key.
165    ///
166    /// The UUID is appended to the timestamp portion of the object key, such that if the blob key
167    /// generated is `date=2022-07-18/1658176486`, setting this field to `true` results
168    /// in an blob key that looks like
169    /// `date=2022-07-18/1658176486-30f6652c-71da-4f9f-800d-a1189c47c547`.
170    ///
171    /// This ensures there are no name collisions, and can be useful in high-volume workloads where
172    /// blob keys must be unique.
173    pub blob_append_uuid: Option<bool>,
174
175    #[serde(flatten)]
176    pub encoding: EncodingConfigWithFraming,
177
178    /// Compression configuration.
179    ///
180    /// All compression algorithms use the default compression level unless otherwise specified.
181    ///
182    /// Some cloud storage API clients and browsers handle decompression transparently, so
183    /// depending on how they are accessed, files may not always appear to be compressed.
184    #[configurable(derived)]
185    #[serde(default = "Compression::gzip_default")]
186    pub compression: Compression,
187
188    /// The set of [blob index tags][blob_index_tags] to apply to created blobs.
189    ///
190    /// Each entry becomes a tag in the `x-ms-tags` header. Azure limits blobs to 10 tags,
191    /// with restricted character sets for keys and values; the service rejects invalid
192    /// configurations.
193    ///
194    /// When authenticating with a shared access signature (SAS), the token must include the
195    /// `Tags` permission in addition to `Read` and `Create`. Azure applies the *Set Blob Tags*
196    /// authorization requirement to the `Put Blob` request that carries these tags, so without
197    /// it tagged uploads fail with an authorization error even when the health check still passes.
198    ///
199    /// When authenticating with an Azure credential (managed identity, workload identity, and so
200    /// on), the identity needs the
201    /// `Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/write` RBAC action.
202    /// The least-privileged built-in role that grants it is *Storage Blob Data Owner*; the
203    /// *Storage Blob Data Contributor* role commonly sufficient for uploads does not include it.
204    ///
205    /// [blob_index_tags]: https://learn.microsoft.com/azure/storage/blobs/storage-blob-index-how-to
206    #[configurable(metadata(docs::additional_props_description = "A single tag."))]
207    #[configurable(metadata(docs::examples = "example_tags()"))]
208    #[serde(default)]
209    pub tags: Option<BTreeMap<String, String>>,
210
211    /// The set of [custom metadata][blob_metadata] `key:value` pairs to apply to created blobs.
212    ///
213    /// Each entry becomes an `x-ms-meta-{key}` header. Azure limits the total size of all
214    /// metadata and restricts key names to ASCII alphanumeric characters and underscores,
215    /// starting with a letter. Non-ASCII values must be Base64-encoded before being set.
216    /// The service rejects invalid configurations. See the [Azure documentation][blob_metadata]
217    /// for current limits.
218    ///
219    /// [blob_metadata]: https://learn.microsoft.com/rest/api/storageservices/set-blob-metadata
220    #[configurable(metadata(docs::additional_props_description = "A key/value pair."))]
221    #[configurable(metadata(docs::advanced))]
222    #[serde(default)]
223    pub metadata: Option<HashMap<String, String>>,
224
225    #[configurable(derived)]
226    #[serde(default)]
227    pub batch: BatchConfig<BulkSizeBasedDefaultBatchSettings>,
228
229    #[configurable(derived)]
230    #[serde(default)]
231    pub request: TowerRequestConfig<AzureBlobTowerRequestConfigDefaults>,
232
233    #[configurable(derived)]
234    #[serde(
235        default,
236        deserialize_with = "crate::serde::bool_or_struct",
237        skip_serializing_if = "crate::serde::is_default"
238    )]
239    pub(super) acknowledgements: AcknowledgementsConfig,
240
241    #[configurable(derived)]
242    #[serde(default)]
243    pub tls: Option<AzureBlobTlsConfig>,
244
245    #[serde(flatten)]
246    pub confinement: ConfinementConfig,
247}
248
249pub fn default_blob_prefix() -> Template {
250    Template::try_from(DEFAULT_KEY_PREFIX).unwrap()
251}
252
253impl GenerateConfig for AzureBlobSinkConfig {
254    fn generate_config() -> serde_json::Value {
255        serde_json::to_value(Self {
256            auth: None,
257            connection_string: Some(String::from("DefaultEndpointsProtocol=https;AccountName=some-account-name;AccountKey=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=;").into()),
258            account_name: None,
259            blob_endpoint: None,
260            container_name: String::from("logs"),
261            blob_prefix: default_blob_prefix(),
262            blob_time_format: Some(String::from("%s")),
263            blob_append_uuid: Some(true),
264            encoding: (Some(NewlineDelimitedEncoderConfig::new()), JsonSerializerConfig::default()).into(),
265            compression: Compression::gzip_default(),
266            tags: None,
267            metadata: None,
268            batch: BatchConfig::default(),
269            request: TowerRequestConfig::default(),
270            acknowledgements: Default::default(),
271            tls: None,
272            confinement: ConfinementConfig::default(),
273        })
274        .unwrap()
275    }
276}
277
278fn example_tags() -> HashMap<String, String> {
279    HashMap::<_, _>::from_iter([
280        ("Project".to_string(), "Blue".to_string()),
281        ("Classification".to_string(), "confidential".to_string()),
282        ("PHI".to_string(), "True".to_string()),
283    ])
284}
285
286#[async_trait::async_trait]
287#[typetag::serde(name = "azure_blob")]
288impl SinkConfig for AzureBlobSinkConfig {
289    fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
290        Some(&self.confinement)
291    }
292
293    fn input(&self) -> Input {
294        Input::new(self.encoding.config().1.input_type() & DataType::Log)
295    }
296
297    fn acknowledgements(&self) -> &AcknowledgementsConfig {
298        &self.acknowledgements
299    }
300
301    fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
302        Some(self)
303    }
304}
305
306#[derive(Clone, Derivative)]
307#[derivative(Debug)]
308pub struct ValidatedAzureBlob {
309    // The connection string contains credentials (AccountKey / SAS token),
310    // so it is intentionally omitted from diagnostics.
311    #[derivative(Debug = "ignore")]
312    parsed_connection_string: ParsedConnectionString,
313    // The container URL may embed a SAS token as its query string, so it is
314    // rendered without the query.
315    #[derivative(Debug(format_with = "fmt_container_url"))]
316    container_url: Url,
317    batcher_settings: BatcherSettings,
318    blob_time_format: String,
319    blob_append_uuid: bool,
320    #[derivative(Debug(format_with = "fmt_confined_blob_prefix"))]
321    confined_blob_prefix: ConfinedTemplate,
322}
323
324/// Formats a container URL without its query string, so a SAS token embedded
325/// as a query parameter is not leaked into diagnostics.
326fn fmt_container_url(url: &Url, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327    let mut url = url.clone();
328    url.set_query(None);
329    fmt::Debug::fmt(&url, f)
330}
331
332/// Formats a confined template as its rendered string.
333fn fmt_confined_blob_prefix(
334    template: &ConfinedTemplate,
335    f: &mut fmt::Formatter<'_>,
336) -> fmt::Result {
337    fmt::Debug::fmt(&template.to_string(), f)
338}
339
340#[async_trait::async_trait]
341impl ValidatedSink for AzureBlobSinkConfig {
342    type Validated = ValidatedAzureBlob;
343
344    fn validate(&self) -> crate::Result<ValidatedAzureBlob> {
345        let connection_string: String = match (
346            &self.connection_string,
347            &self.account_name,
348            &self.blob_endpoint,
349        ) {
350            (Some(connstr), None, None) => connstr.inner().into(),
351            (None, Some(account_name), None) => {
352                if self.auth.is_none() {
353                    return Err(
354                        "`auth` configuration must be provided when using `account_name`".into(),
355                    );
356                }
357                format!("AccountName={}", account_name)
358            }
359            (None, None, Some(blob_endpoint)) => {
360                if self.auth.is_none() {
361                    return Err(
362                        "`auth` configuration must be provided when using `blob_endpoint`".into(),
363                    );
364                }
365                // BlobEndpoint must always end in a trailing slash
366                let blob_endpoint = if blob_endpoint.ends_with('/') {
367                    blob_endpoint.clone()
368                } else {
369                    format!("{}/", blob_endpoint)
370                };
371                format!("BlobEndpoint={}", blob_endpoint)
372            }
373            (None, None, None) => {
374                return Err("One of `connection_string`, `account_name`, or `blob_endpoint` must be provided".into());
375            }
376            (Some(_), Some(_), _) => {
377                return Err("Cannot provide both `connection_string` and `account_name`".into());
378            }
379            (Some(_), _, Some(_)) => {
380                return Err("Cannot provide both `connection_string` and `blob_endpoint`".into());
381            }
382            (_, Some(_), Some(_)) => {
383                return Err("Cannot provide both `account_name` and `blob_endpoint`".into());
384            }
385        };
386
387        // Parse the resolved connection string and container URL up front.
388        // Token credential construction remains in `build`; the shared key
389        // policy is constructed here purely to validate the account key base64.
390        let parsed_connection_string = ParsedConnectionString::parse(&connection_string)
391            .map_err(|e| format!("Invalid connection string: {e}"))?;
392        // Reject the deterministic conflict between credentials implied by the
393        // connection string (SAS or Shared Key) and an explicit `auth`.
394        validate_auth_conflict(&parsed_connection_string.auth(), &self.auth)?;
395        // Force the base64 decode of a Shared Key account key during validation so
396        // malformed keys are rejected up front rather than at build time.
397        if let Auth::SharedKey {
398            account_name,
399            account_key,
400        } = parsed_connection_string.auth()
401        {
402            SharedKeyAuthorizationPolicy::new(
403                account_name,
404                account_key,
405                // Use an Azurite-supported storage service version
406                String::from("2025-11-05"),
407            )
408            .map_err(|e| format!("Failed to create SharedKey policy: {e}"))?;
409        }
410        let container_url = parsed_connection_string
411            .container_url(&self.container_name)
412            .map_err(|e| format!("Failed to build container URL: {e}"))?;
413        let container_url =
414            Url::parse(&container_url).map_err(|e| format!("Invalid container URL: {e}"))?;
415
416        let batcher_settings = self.batch.into_batcher_settings()?;
417
418        let blob_time_format = self
419            .blob_time_format
420            .as_ref()
421            .cloned()
422            .unwrap_or_else(|| DEFAULT_FILENAME_TIME_FORMAT.into());
423        let blob_append_uuid = self
424            .blob_append_uuid
425            .unwrap_or(DEFAULT_FILENAME_APPEND_UUID);
426
427        let confined_blob_prefix = self.confined_blob_prefix()?;
428
429        Ok(ValidatedAzureBlob {
430            parsed_connection_string,
431            container_url,
432            batcher_settings,
433            blob_time_format,
434            blob_append_uuid,
435            confined_blob_prefix,
436        })
437    }
438
439    async fn build(
440        &self,
441        validated: &ValidatedAzureBlob,
442        cx: SinkContext,
443    ) -> crate::Result<(VectorSink, Healthcheck)> {
444        let client = build_client(
445            self.auth.clone(),
446            validated.parsed_connection_string.clone(),
447            validated.container_url.clone(),
448            cx.proxy(),
449            self.tls.clone(),
450        )
451        .await?;
452
453        let healthcheck = build_healthcheck(self.container_name.clone(), Arc::clone(&client))?;
454        let sink = self.build_processor(client, validated)?;
455        Ok((sink, healthcheck))
456    }
457}
458
459const DEFAULT_KEY_PREFIX: &str = "blob/%F/";
460const DEFAULT_FILENAME_TIME_FORMAT: &str = "%s";
461const DEFAULT_FILENAME_APPEND_UUID: bool = true;
462
463impl AzureBlobSinkConfig {
464    pub fn build_processor(
465        &self,
466        client: Arc<BlobContainerClient>,
467        validated: &ValidatedAzureBlob,
468    ) -> crate::Result<VectorSink> {
469        let request_limits = self.request.into_settings();
470        let service = ServiceBuilder::new()
471            .settings(request_limits, AzureBlobRetryLogic)
472            .service(AzureBlobService::new(client));
473
474        let (framer, serializer) = self.encoding.build(SinkType::MessageBased)?;
475        let encoder = Encoder::<Framer>::new(framer, serializer);
476
477        let request_options = AzureBlobRequestOptions {
478            container_name: self.container_name.clone(),
479            blob_time_format: validated.blob_time_format.clone(),
480            blob_append_uuid: validated.blob_append_uuid,
481            encoder: (self.encoding.transformer(), encoder),
482            compression: self.compression,
483            tags: self.tags.clone(),
484            metadata: self.metadata.clone(),
485        };
486
487        let sink = AzureBlobSink::new(
488            service,
489            request_options,
490            KeyPartitioner::new(validated.confined_blob_prefix.clone(), None),
491            validated.batcher_settings,
492        );
493
494        Ok(VectorSink::from_event_streamsink(sink))
495    }
496
497    pub fn key_partitioner(&self) -> crate::Result<KeyPartitioner> {
498        let tpl = self.confined_blob_prefix()?;
499        Ok(KeyPartitioner::new(tpl, None))
500    }
501
502    fn confined_blob_prefix(&self) -> crate::Result<ConfinedTemplate> {
503        self.blob_prefix
504            .clone()
505            .confine(&self.confinement, Self::NAME, "blob_prefix")
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::{
513        sinks::azure_common::config::SpecificAzureCredential, template::ConfinementConfig,
514    };
515
516    fn test_config(
517        connection_string: Option<&str>,
518        auth: Option<AzureAuthentication>,
519    ) -> AzureBlobSinkConfig {
520        AzureBlobSinkConfig {
521            auth,
522            connection_string: connection_string.map(|s| s.to_string().into()),
523            tags: None,
524            metadata: None,
525            account_name: None,
526            blob_endpoint: None,
527            container_name: "my-logs".to_string(),
528            blob_prefix: "blob".try_into().unwrap(),
529            blob_time_format: None,
530            blob_append_uuid: None,
531            encoding: (
532                Some(NewlineDelimitedEncoderConfig::new()),
533                JsonSerializerConfig::default(),
534            )
535                .into(),
536            compression: Compression::gzip_default(),
537            batch: BatchConfig::default(),
538            request: TowerRequestConfig::default(),
539            acknowledgements: Default::default(),
540            tls: None,
541            confinement: ConfinementConfig::default(),
542        }
543    }
544
545    #[test]
546    fn generate_config() {
547        crate::test_util::test_generate_config::<AzureBlobSinkConfig>();
548    }
549
550    #[test]
551    fn confinement_rejects_unconfined_blob_prefix() {
552        let template = Template::try_from("{{ tenant }}").unwrap();
553        let err = template
554            .confine(&ConfinementConfig::default(), "azure_blob", "blob_prefix")
555            .unwrap_err();
556        assert!(
557            err.to_string().contains("no literal string prefix"),
558            "unexpected error: {err}"
559        );
560    }
561
562    #[test]
563    fn confinement_opt_out_allows_unconfined_blob_prefix() {
564        let cfg = ConfinementConfig {
565            dangerously_allow_unconfined_template_resolution: true,
566        };
567        let template = Template::try_from("{{ tenant }}").unwrap();
568        assert!(template.confine(&cfg, "azure_blob", "blob_prefix").is_ok());
569    }
570
571    #[test]
572    fn confinement_blocks_dotdot_escape_at_render() {
573        use crate::event::Event;
574        use vector_lib::event::LogEvent;
575        use vrl::event_path;
576
577        let template = Template::try_from("safe/{{ tenant }}/").unwrap();
578        let template = template
579            .confine(&ConfinementConfig::default(), "azure_blob", "blob_prefix")
580            .unwrap();
581        let mut event = Event::Log(LogEvent::from("x"));
582        event
583            .as_mut_log()
584            .insert(event_path!("tenant"), "../../escape");
585        assert!(template.render_string(&event).is_err());
586    }
587
588    #[test]
589    fn validate_produces_usable_values() {
590        let config = AzureBlobSinkConfig {
591            auth: None,
592            connection_string: Some("AccountName=mylogstorage".to_string().into()),
593            tags: None,
594            metadata: None,
595            account_name: None,
596            blob_endpoint: None,
597            container_name: "my-logs".to_string(),
598            blob_prefix: "blob".try_into().unwrap(),
599            blob_time_format: None,
600            blob_append_uuid: None,
601            encoding: (
602                Some(NewlineDelimitedEncoderConfig::new()),
603                JsonSerializerConfig::default(),
604            )
605                .into(),
606            compression: Compression::gzip_default(),
607            batch: BatchConfig::default(),
608            request: TowerRequestConfig::default(),
609            acknowledgements: Default::default(),
610            tls: None,
611            confinement: ConfinementConfig::default(),
612        };
613
614        let validated = config.validate().expect("validation should succeed");
615        assert_eq!(validated.blob_time_format, "%s");
616        assert!(validated.blob_append_uuid);
617        assert_eq!(validated.confined_blob_prefix.to_string(), "blob");
618    }
619
620    #[test]
621    fn validated_debug_redacts_connection_string() {
622        let account_key = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
623        let config = test_config(
624            Some(&format!(
625                "AccountName=mylogstorage;AccountKey={account_key}"
626            )),
627            None,
628        );
629        let validated = config.validate().expect("validation should succeed");
630        let debug = format!("{validated:?}");
631        assert!(
632            !debug.contains(account_key),
633            "Debug output must not leak the connection string: {debug}"
634        );
635    }
636
637    #[test]
638    fn validated_debug_redacts_sas_token() {
639        let sas_sig = "supersecretsignature";
640        let config = test_config(
641            Some(&format!(
642                "BlobEndpoint=https://mylogstorage.blob.core.windows.net/;SharedAccessSignature=sv=2022-11-02&ss=b&srt=sco&sp=rcw&se=2099-01-01T00:00:00Z&sig={sas_sig}"
643            )),
644            None,
645        );
646        let validated = config.validate().expect("validation should succeed");
647        let debug = format!("{validated:?}");
648        assert!(
649            !debug.contains(sas_sig),
650            "Debug output must not leak the SAS token: {debug}"
651        );
652    }
653
654    #[test]
655    fn validate_rejects_malformed_connection_string() {
656        let config = AzureBlobSinkConfig {
657            auth: None,
658            connection_string: Some("not-a-valid-connection-string".to_string().into()),
659            tags: None,
660            metadata: None,
661            account_name: None,
662            blob_endpoint: None,
663            container_name: "my-logs".to_string(),
664            blob_prefix: "blob".try_into().unwrap(),
665            blob_time_format: None,
666            blob_append_uuid: None,
667            encoding: (
668                Some(NewlineDelimitedEncoderConfig::new()),
669                JsonSerializerConfig::default(),
670            )
671                .into(),
672            compression: Compression::gzip_default(),
673            batch: BatchConfig::default(),
674            request: TowerRequestConfig::default(),
675            acknowledgements: Default::default(),
676            tls: None,
677            confinement: ConfinementConfig::default(),
678        };
679
680        assert!(config.validate().is_err());
681    }
682
683    #[test]
684    fn validate_rejects_connection_string_sas_with_auth() {
685        let config = test_config(
686            Some(
687                "BlobEndpoint=https://mylogstorage.blob.core.windows.net/;SharedAccessSignature=sv=2022-11-02&ss=b&srt=sco&sp=rcw&se=2099-01-01T00:00:00Z&sig=...",
688            ),
689            Some(AzureAuthentication::Specific(
690                SpecificAzureCredential::ManagedIdentity {
691                    user_assigned_managed_identity_id: None,
692                    user_assigned_managed_identity_id_type: None,
693                },
694            )),
695        );
696
697        let err = config.validate().expect_err("validation should fail");
698        assert!(
699            err.to_string()
700                .contains("Cannot use both SAS token and another Azure Authentication method"),
701            "unexpected error: {err}"
702        );
703    }
704
705    #[test]
706    fn validate_rejects_connection_string_shared_key_with_auth() {
707        let config = test_config(
708            Some(
709                "DefaultEndpointsProtocol=https;AccountName=mylogstorage;AccountKey=base64key==;EndpointSuffix=core.windows.net",
710            ),
711            Some(AzureAuthentication::Specific(
712                SpecificAzureCredential::ManagedIdentity {
713                    user_assigned_managed_identity_id: None,
714                    user_assigned_managed_identity_id_type: None,
715                },
716            )),
717        );
718
719        let err = config.validate().expect_err("validation should fail");
720        assert!(
721            err.to_string()
722                .contains("Cannot use both Shared Key and another Azure Authentication method"),
723            "unexpected error: {err}"
724        );
725    }
726
727    #[test]
728    fn validate_accepts_connection_string_without_creds_with_auth() {
729        let config = test_config(
730            Some("AccountName=mylogstorage"),
731            Some(AzureAuthentication::Specific(
732                SpecificAzureCredential::ManagedIdentity {
733                    user_assigned_managed_identity_id: None,
734                    user_assigned_managed_identity_id_type: None,
735                },
736            )),
737        );
738
739        config.validate().expect("validation should succeed");
740    }
741
742    #[test]
743    fn validate_accepts_connection_string_with_creds_without_auth() {
744        let config = test_config(
745            Some(
746                "DefaultEndpointsProtocol=https;AccountName=mylogstorage;AccountKey=MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=;EndpointSuffix=core.windows.net",
747            ),
748            None,
749        );
750
751        config.validate().expect("validation should succeed");
752    }
753
754    #[test]
755    fn validate_rejects_invalid_base64_account_key() {
756        let config = test_config(
757            Some(
758                "DefaultEndpointsProtocol=https;AccountName=mylogstorage;AccountKey=base64key==;EndpointSuffix=core.windows.net",
759            ),
760            None,
761        );
762
763        let err = config.validate().expect_err("validation should fail");
764        assert!(
765            err.to_string()
766                .contains("Failed to create SharedKey policy"),
767            "unexpected error: {err}"
768        );
769    }
770}
771
772#[derive(Debug, Clone)]
773pub struct AzureBlobRequest {
774    pub blob_data: Bytes,
775    pub content_encoding: Option<&'static str>,
776    pub content_type: &'static str,
777    pub metadata: AzureBlobMetadata,
778    pub request_metadata: RequestMetadata,
779    /// Pre-encoded `x-ms-tags` header value (`k=v&k=v`), or `None` to omit the header.
780    pub tags: Option<String>,
781    /// Custom blob metadata. Each entry becomes an `x-ms-meta-{key}` header.
782    pub blob_metadata: Option<std::collections::HashMap<String, String>>,
783}
784
785impl Finalizable for AzureBlobRequest {
786    fn take_finalizers(&mut self) -> EventFinalizers {
787        std::mem::take(&mut self.metadata.finalizers)
788    }
789}
790
791impl MetaDescriptive for AzureBlobRequest {
792    fn get_metadata(&self) -> &RequestMetadata {
793        &self.request_metadata
794    }
795
796    fn metadata_mut(&mut self) -> &mut RequestMetadata {
797        &mut self.request_metadata
798    }
799}
800
801#[derive(Clone, Debug)]
802pub struct AzureBlobMetadata {
803    pub partition_key: String,
804    pub count: usize,
805    pub finalizers: EventFinalizers,
806}
807
808#[derive(Debug, Clone)]
809pub struct AzureBlobRetryLogic;
810
811impl RetryLogic for AzureBlobRetryLogic {
812    type Error = Error;
813    type Request = AzureBlobRequest;
814    type Response = AzureBlobResponse;
815
816    fn is_retriable_error(&self, error: &Self::Error) -> bool {
817        match error.http_status() {
818            Some(code) => code.is_server_error() || code == StatusCode::TooManyRequests,
819            None => false,
820        }
821    }
822}
823
824#[derive(Debug)]
825pub struct AzureBlobResponse {
826    pub events_byte_size: GroupedCountByteSize,
827    pub byte_size: usize,
828}
829
830impl DriverResponse for AzureBlobResponse {
831    fn event_status(&self) -> EventStatus {
832        EventStatus::Delivered
833    }
834
835    fn events_sent(&self) -> &GroupedCountByteSize {
836        &self.events_byte_size
837    }
838
839    fn bytes_sent(&self) -> Option<usize> {
840        Some(self.byte_size)
841    }
842}
843
844#[derive(Debug, Snafu)]
845pub enum HealthcheckError {
846    #[snafu(display("Invalid connection string specified"))]
847    InvalidCredentials,
848    #[snafu(display("Container: {:?} not found", container))]
849    UnknownContainer { container: String },
850    #[snafu(display("Unknown status code: {}", status))]
851    Unknown { status: StatusCode },
852}
853
854pub fn build_healthcheck(
855    container_name: String,
856    client: Arc<BlobContainerClient>,
857) -> crate::Result<Healthcheck> {
858    let healthcheck = async move {
859        let resp: crate::Result<()> = match client.get_properties(None).await {
860            Ok(_) => Ok(()),
861            Err(error) => {
862                let code = error.http_status();
863                Err(match code {
864                    Some(StatusCode::Forbidden) => Box::new(HealthcheckError::InvalidCredentials),
865                    Some(StatusCode::NotFound) => Box::new(HealthcheckError::UnknownContainer {
866                        container: container_name,
867                    }),
868                    Some(status) => Box::new(HealthcheckError::Unknown { status }),
869                    None => "unknown status code".into(),
870                })
871            }
872        };
873        resp
874    };
875
876    Ok(healthcheck.boxed())
877}
878
879/// Reject the deterministic conflict between credentials implied by the
880/// connection string (SAS or Shared Key) and an explicit `auth` configuration.
881///
882/// Pure structural check: no credential construction, no I/O, no async. Shared
883/// by `validate` and `build_client`.
884fn validate_auth_conflict(
885    parsed_auth: &Auth,
886    auth: &Option<AzureAuthentication>,
887) -> crate::Result<()> {
888    match (parsed_auth, auth) {
889        (Auth::Sas { .. }, Some(_)) => Err(
890            "Cannot use both SAS token and another Azure Authentication method at the same time"
891                .into(),
892        ),
893        (Auth::SharedKey { .. }, Some(_)) => Err(
894            "Cannot use both Shared Key and another Azure Authentication method at the same time"
895                .into(),
896        ),
897        _ => Ok(()),
898    }
899}
900
901pub async fn build_client(
902    auth: Option<AzureAuthentication>,
903    parsed: ParsedConnectionString,
904    url: Url,
905    proxy: &crate::config::ProxyConfig,
906    tls: Option<AzureBlobTlsConfig>,
907) -> crate::Result<Arc<BlobContainerClient>> {
908    // The connection string and container URL were parsed and validated during
909    // `validate`; only credential construction remains here.
910    let mut credential: Option<Arc<dyn TokenCredential>> = None;
911
912    // The deterministic conflict between connection-string-implied credentials
913    // and an explicit `auth` was already rejected during `validate`; re-check
914    // here so `build_client` stays safe when called directly (e.g. integration
915    // tests).
916    validate_auth_conflict(&parsed.auth(), &auth)?;
917
918    // Prepare options; attach Shared Key policy if needed
919    let mut options = BlobContainerClientOptions::default();
920    match (parsed.auth(), &auth) {
921        (Auth::None, None) => {
922            warn!("No authentication method provided, requests will be anonymous.");
923        }
924        (Auth::Sas { .. }, None) => {
925            info!("Using SAS token authentication.");
926        }
927        (
928            Auth::SharedKey {
929                account_name,
930                account_key,
931            },
932            None,
933        ) => {
934            info!("Using Shared Key authentication.");
935
936            let policy = SharedKeyAuthorizationPolicy::new(
937                account_name,
938                account_key,
939                // Use an Azurite-supported storage service version
940                String::from("2025-11-05"),
941            )
942            .map_err(|e| format!("Failed to create SharedKey policy: {e}"))?;
943            options
944                .client_options
945                .per_call_policies
946                .push(Arc::new(policy));
947        }
948        (Auth::None, Some(AzureAuthentication::Specific(..))) => {
949            info!("Using Azure Authentication method.");
950            let credential_result: Arc<dyn TokenCredential> =
951                auth.unwrap().credential().await.map_err(|e| {
952                    Error::with_message(
953                        ErrorKind::Credential,
954                        format!("Failed to configure Azure Authentication: {e}"),
955                    )
956                })?;
957            credential = Some(credential_result);
958        }
959        (Auth::Sas { .. }, Some(AzureAuthentication::Specific(..))) => {
960            unreachable!("connection string SAS + explicit auth rejected in validate")
961        }
962        (Auth::SharedKey { .. }, Some(AzureAuthentication::Specific(..))) => {
963            unreachable!("connection string Shared Key + explicit auth rejected in validate")
964        }
965        #[cfg(test)]
966        (Auth::None, Some(AzureAuthentication::MockCredential)) => {
967            warn!("Using mock token credential authentication.");
968            credential = Some(auth.unwrap().credential().await.unwrap());
969        }
970        #[cfg(test)]
971        (_, Some(AzureAuthentication::MockCredential)) => {
972            unreachable!("connection string auth + mock credential rejected in validate")
973        }
974    }
975
976    // Use reqwest v0.13 since Azure SDK only implements HttpClient for reqwest::Client v0.13
977    let mut reqwest_builder = reqwest_13::ClientBuilder::new();
978    let bypass_proxy = {
979        let host = url.host_str().unwrap_or("");
980        let port = url.port();
981        proxy.no_proxy.matches(host)
982            || port
983                .map(|p| proxy.no_proxy.matches(&format!("{}:{}", host, p)))
984                .unwrap_or(false)
985    };
986    if bypass_proxy || !proxy.enabled {
987        // Ensure no proxy (and disable any potential system proxy auto-detection)
988        reqwest_builder = reqwest_builder.no_proxy();
989    } else {
990        if let Some(http) = &proxy.http {
991            let p = reqwest_13::Proxy::http(http)
992                .map_err(|e| format!("Invalid HTTP proxy URL: {e}"))?;
993            // If credentials are embedded in the proxy URL, reqwest will handle them.
994            reqwest_builder = reqwest_builder.proxy(p);
995        }
996        if let Some(https) = &proxy.https {
997            let p = reqwest_13::Proxy::https(https)
998                .map_err(|e| format!("Invalid HTTPS proxy URL: {e}"))?;
999            // If credentials are embedded in the proxy URL, reqwest will handle them.
1000            reqwest_builder = reqwest_builder.proxy(p);
1001        }
1002    }
1003
1004    if let Some(AzureBlobTlsConfig { ca_file }) = &tls
1005        && let Some(ca_file) = ca_file
1006    {
1007        let mut buf = Vec::new();
1008        File::open(ca_file)?.read_to_end(&mut buf)?;
1009        let cert = reqwest_13::Certificate::from_pem(&buf)?;
1010
1011        warn!("Adding TLS root certificate from {}", ca_file.display());
1012        reqwest_builder = reqwest_builder.add_root_certificate(cert);
1013    }
1014
1015    options.client_options.transport = Some(azure_core::http::Transport::new(std::sync::Arc::new(
1016        reqwest_builder
1017            .build()
1018            .map_err(|e| format!("Failed to build reqwest client: {e}"))?,
1019    )));
1020    let client =
1021        BlobContainerClient::new(url, credential, Some(options)).map_err(|e| format!("{e}"))?;
1022    Ok(Arc::new(client))
1023}