Skip to main content

brows3r_lib/profiles/
compat_flags.rs

1//! Provider compatibility flags.
2//!
3//! `CompatFlags` controls per-profile S3 compatibility knobs for providers
4//! such as MinIO, Cloudflare R2, Wasabi, and LocalStack.
5//!
6//! # OCP contract
7//! Adding a new flag means adding one field here. The `flags_schema` version
8//! field is the contract: existing consumers are forward-compat via the
9//! `unknown` passthrough map.  Removing or renaming a field is a breaking
10//! change that requires a schema version bump.
11//!
12//! # Versioning
13//! `flags_schema = 1` covers all v1 flags. Unknown fields written by a newer
14//! version of the app are preserved in `unknown` so a downgrade does not lose
15//! custom configuration.
16
17use std::collections::BTreeMap;
18
19use aws_sdk_s3::config::{Builder as S3ConfigBuilder, RequestChecksumCalculation};
20use serde::{Deserialize, Serialize};
21
22// ---------------------------------------------------------------------------
23// Enums
24// ---------------------------------------------------------------------------
25
26/// S3 bucket addressing style applied to every request for this profile.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AddressingStyle {
30    /// `bucket.s3.amazonaws.com` — default for AWS.
31    Virtual,
32    /// `s3.amazonaws.com/bucket` — required for MinIO, LocalStack, Ceph.
33    Path,
34    /// Let the SDK choose based on the bucket name validity rules.
35    Auto,
36}
37
38impl Default for AddressingStyle {
39    fn default() -> Self {
40        Self::Auto
41    }
42}
43
44/// Signature algorithm used to sign requests for this profile.
45///
46/// `V2` is supported as a degraded fallback only for ancient providers
47/// that have not yet adopted SigV4. Using V2 against AWS will fail.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum SignatureVersion {
51    V4,
52    /// Legacy — accepted for ancient compat providers only.
53    V2,
54}
55
56impl Default for SignatureVersion {
57    fn default() -> Self {
58        Self::V4
59    }
60}
61
62/// Whether the SDK should compute and send payload checksums.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ChecksumMode {
66    /// Let the SDK decide (CRC-32 for SDKv2+).
67    Auto,
68    /// Disable payload checksums — required for some older compat providers.
69    Disabled,
70}
71
72impl Default for ChecksumMode {
73    fn default() -> Self {
74        Self::Auto
75    }
76}
77
78/// Whether bucket names must follow strict AWS naming rules or a relaxed set.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum BucketNameValidation {
82    /// AWS rules: 3-63 chars, lowercase letters/numbers/hyphens, no IP-style names.
83    Strict,
84    /// Lax: accept any non-empty string (needed for some on-prem deployments).
85    Lax,
86}
87
88impl Default for BucketNameValidation {
89    fn default() -> Self {
90        Self::Strict
91    }
92}
93
94// ---------------------------------------------------------------------------
95// CompatFlags struct
96// ---------------------------------------------------------------------------
97
98/// Per-profile compatibility flags for S3-compatible storage providers.
99///
100/// All fields have sane defaults that work with AWS S3. Override only the
101/// fields that your provider requires.
102///
103/// Serialization uses `camelCase` for JSON round-trips with the frontend.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct CompatFlags {
107    // ------------------------------------------------------------------
108    // Schema version — increment on breaking changes, not on additions.
109    // ------------------------------------------------------------------
110    /// Schema version that emitted these flags. Used for forward-compat
111    /// migration. Always write `1` for v1 flags; a future bump to `2`
112    /// signals a format change.
113    #[serde(default = "default_flags_schema")]
114    pub flags_schema: u32,
115
116    // ------------------------------------------------------------------
117    // Endpoint / addressing
118    // ------------------------------------------------------------------
119    /// Custom base URL for the S3 endpoint, e.g. `http://localhost:9000`.
120    /// `None` means use the AWS regional endpoint.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub endpoint_url: Option<String>,
123
124    /// Pin all requests for this profile to a fixed region, ignoring any
125    /// region the SDK would otherwise auto-detect.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub region_override: Option<String>,
128
129    /// How to form the S3 endpoint URL for bucket operations.
130    #[serde(default)]
131    pub addressing_style: AddressingStyle,
132
133    // ------------------------------------------------------------------
134    // Auth / signing
135    // ------------------------------------------------------------------
136    /// Signature algorithm. Default V4; V2 is a degraded-compat escape hatch.
137    #[serde(default)]
138    pub signature_version: SignatureVersion,
139
140    // ------------------------------------------------------------------
141    // Request behavior
142    // ------------------------------------------------------------------
143    /// Whether to send payload checksums on uploads.
144    #[serde(default)]
145    pub checksum_mode: ChecksumMode,
146
147    /// Accept TLS certificates that fail validation (self-signed, expired).
148    /// **Security risk** — only use for development / private networks.
149    #[serde(default)]
150    pub accept_invalid_tls: bool,
151
152    /// Send `Expect: 100-continue` on PUT requests. Some proxies and older
153    /// providers reject the header; set to `false` to suppress it.
154    #[serde(default = "default_true")]
155    pub expect_continue: bool,
156
157    /// Use chunked upload encoding (`Transfer-Encoding: chunked`) instead of
158    /// setting `Content-Length` upfront. Required for streaming uploads where
159    /// the total size is unknown at request start.
160    #[serde(default)]
161    pub chunked_upload: bool,
162
163    // ------------------------------------------------------------------
164    // Validation
165    // ------------------------------------------------------------------
166    /// How strictly to validate bucket names before sending requests.
167    #[serde(default)]
168    pub bucket_name_validation: BucketNameValidation,
169
170    // ------------------------------------------------------------------
171    // Forward-compat passthrough
172    // ------------------------------------------------------------------
173    /// Unknown flags written by a newer schema version. Preserved verbatim
174    /// on read and re-serialized on write so a downgrade does not lose them.
175    #[serde(default, flatten)]
176    pub unknown: BTreeMap<String, serde_json::Value>,
177}
178
179fn default_flags_schema() -> u32 {
180    1
181}
182
183fn default_true() -> bool {
184    true
185}
186
187impl Default for CompatFlags {
188    fn default() -> Self {
189        Self {
190            flags_schema: 1,
191            endpoint_url: None,
192            region_override: None,
193            addressing_style: AddressingStyle::default(),
194            signature_version: SignatureVersion::default(),
195            checksum_mode: ChecksumMode::default(),
196            accept_invalid_tls: false,
197            expect_continue: true,
198            chunked_upload: false,
199            bucket_name_validation: BucketNameValidation::default(),
200            unknown: BTreeMap::new(),
201        }
202    }
203}
204
205// ---------------------------------------------------------------------------
206// CompatFlagApply — return type of apply_to_s3_config_builder
207// ---------------------------------------------------------------------------
208
209/// The current schema version understood by this build.
210///
211/// When a stored `CompatFlags::flags_schema` differs from this value the apply
212/// function emits a warning and applies only the known subset.
213pub const CURRENT_FLAGS_SCHEMA: u32 = 1;
214
215/// Result of applying `CompatFlags` onto an `S3ConfigBuilder`.
216///
217/// `builder` is the mutated builder ready for `.build()`.
218/// `warnings` is the list of human-readable degradation or forward-compat
219/// messages that should be surfaced to the user (e.g. via the notification log).
220pub struct CompatFlagApply {
221    pub builder: S3ConfigBuilder,
222    pub warnings: Vec<String>,
223}
224
225/// Apply all v1 compat flags from `flags` onto `builder` and return the
226/// updated builder together with any warnings.
227///
228/// # Forward-compat contract
229///
230/// - If `flags.flags_schema != CURRENT_FLAGS_SCHEMA`, a warning is added but
231///   the known fields are still applied — the function always returns `Ok`.
232/// - Each key in `flags.unknown` (written by a newer schema version) produces
233///   a single "ignored" warning rather than an error.
234///
235/// # Flags wiring summary
236///
237/// | Flag | Wiring |
238/// |---|---|
239/// | `endpoint_url` | SDK loader (applied before `S3ConfigBuilder`) — caller must handle |
240/// | `region_override` | SDK loader — caller must handle |
241/// | `addressing_style` | `force_path_style(true/false/unset)` |
242/// | `signature_version` | V4 always (V2 unsupported by aws-sdk-s3 v1; emits warning) |
243/// | `checksum_mode` | `request_checksum_calculation(WhenRequired)` when Disabled |
244/// | `accept_invalid_tls` | feature-gated; caller injects a custom HTTP connector |
245/// | `expect_continue` | caller injects a custom HTTP connector |
246/// | `chunked_upload` | stored; not yet wired to SDK (open-ended, see comment) |
247/// | `bucket_name_validation` | Lax noted in warning (no SDK-level hook in v1) |
248pub fn apply_to_s3_config_builder(
249    flags: &CompatFlags,
250    builder: S3ConfigBuilder,
251) -> CompatFlagApply {
252    let mut builder = builder;
253    let mut warnings: Vec<String> = Vec::new();
254
255    // ------------------------------------------------------------------
256    // Schema version check — warn but continue with the known subset.
257    // ------------------------------------------------------------------
258    if flags.flags_schema != CURRENT_FLAGS_SCHEMA {
259        warnings.push(format!(
260            "Compat flags schema mismatch (file={} expected={}); \
261             applying known subset only",
262            flags.flags_schema, CURRENT_FLAGS_SCHEMA
263        ));
264    }
265
266    // ------------------------------------------------------------------
267    // addressing_style → force_path_style
268    //
269    // Path  → force_path_style(true)
270    // Virtual → force_path_style(false)
271    // Auto  → leave unset (SDK default: choose based on bucket name)
272    // ------------------------------------------------------------------
273    match flags.addressing_style {
274        AddressingStyle::Path => {
275            builder = builder.force_path_style(true);
276        }
277        AddressingStyle::Virtual => {
278            builder = builder.force_path_style(false);
279        }
280        AddressingStyle::Auto => {
281            // Leave unset — SDK decides.
282        }
283    }
284
285    // ------------------------------------------------------------------
286    // signature_version — V2 is not supported by aws-sdk-s3 v1.
287    // We log a warning and fall back to V4 (the SDK's only signing impl).
288    // ------------------------------------------------------------------
289    if matches!(flags.signature_version, SignatureVersion::V2) {
290        warnings.push(
291            "Signature V2 is unsupported by aws-sdk-s3 v1; using V4 instead. \
292             Requests to providers that require V2 will fail."
293                .to_string(),
294        );
295        // No builder mutation needed — V4 is the SDK default.
296    }
297
298    // ------------------------------------------------------------------
299    // checksum_mode → request_checksum_calculation
300    //
301    // Disabled → WhenRequired (suppress checksums except where mandatory)
302    // Auto     → leave unset (SDK default: CRC-32 for SDKv2+)
303    //
304    // Response checksum validation (ResponseChecksumValidation) is not
305    // available as a top-level builder setter in aws-sdk-s3 v1.x; it is
306    // controlled at the operation level.  We set request-side only and
307    // document the gap.
308    // ------------------------------------------------------------------
309    if matches!(flags.checksum_mode, ChecksumMode::Disabled) {
310        builder = builder.request_checksum_calculation(RequestChecksumCalculation::WhenRequired);
311        // Note: response_checksum_validation is an operation-level concern in
312        // aws-sdk-s3 v1 and cannot be set globally on the config builder.
313        // The intent (disable response-side checksum verification) is noted
314        // here for future wiring when the SDK exposes a config-level setter.
315    }
316
317    // ------------------------------------------------------------------
318    // accept_invalid_tls — gated behind the `compat_invalid_tls` feature.
319    //
320    // The actual connector replacement (dangerous TLS bypass) is injected
321    // by the caller (ClientBuilder::build) which must detect this flag and
322    // provide a custom http_client with cert verification disabled.
323    // Here we emit a prominent warning when the flag is set so it is
324    // visible in the notification log regardless of feature status.
325    // ------------------------------------------------------------------
326    if flags.accept_invalid_tls {
327        warnings.push(
328            "accept_invalid_tls is enabled: TLS certificate validation is \
329             disabled. Only use this in trusted private networks."
330                .to_string(),
331        );
332        // The feature gate and connector replacement live in the HTTP
333        // client layer (see ClientBuilder::build and the `compat_invalid_tls`
334        // feature in Cargo.toml). This function only records the warning.
335    }
336
337    // ------------------------------------------------------------------
338    // expect_continue — controls the `Expect: 100-continue` header on PUTs.
339    //
340    // The aws-sdk-s3 v1 SDK manages this via the HTTP connector layer rather
341    // than via a config-builder setter. The caller (ClientBuilder) must
342    // inspect `flags.expect_continue` and configure the connector accordingly.
343    // We document the gap here; no builder mutation is possible in v1.
344    // ------------------------------------------------------------------
345    // No builder mutation. Caller is responsible for connector-level wiring.
346
347    // ------------------------------------------------------------------
348    // chunked_upload — controls Transfer-Encoding: chunked on PUTs.
349    //
350    // aws-sdk-s3 v1 does not expose a global config toggle for chunked
351    // transfer encoding; it is an operation-level option set per-request
352    // (e.g. via `put_object().send()` with a streaming body). The flag is
353    // stored on `CompatFlags` for future wiring. We record the intent in a
354    // warning so the user knows it is not yet active at the SDK level.
355    // ------------------------------------------------------------------
356    if flags.chunked_upload {
357        warnings.push(
358            "chunked_upload is set but is not yet wired to the SDK config in v1; \
359             PUTs will use Content-Length mode. This flag is reserved for future use."
360                .to_string(),
361        );
362    }
363
364    // ------------------------------------------------------------------
365    // bucket_name_validation — Strict is the SDK default.
366    //
367    // Lax mode (accept any non-empty bucket name) cannot be expressed via
368    // a top-level S3ConfigBuilder setter in aws-sdk-s3 v1. It would require
369    // a custom operation interceptor. We record the intent in a warning.
370    // ------------------------------------------------------------------
371    if matches!(flags.bucket_name_validation, BucketNameValidation::Lax) {
372        warnings.push(
373            "bucket_name_validation=Lax is noted but cannot be enforced \
374             via the S3 config builder in aws-sdk-s3 v1; SDK-level bucket name \
375             validation (strict) remains active."
376                .to_string(),
377        );
378    }
379
380    // ------------------------------------------------------------------
381    // Forward-compat: unknown flags from a newer schema version.
382    // Each unknown key surfaces as a single ignored-flag warning.
383    // ------------------------------------------------------------------
384    for key in flags.unknown.keys() {
385        warnings.push(format!(
386            "Unknown compat flag '{}' ignored (schema_version mismatch?)",
387            key
388        ));
389    }
390
391    CompatFlagApply { builder, warnings }
392}
393
394// ---------------------------------------------------------------------------
395// Tests
396// ---------------------------------------------------------------------------
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn default_flags_have_expected_values() {
404        let f = CompatFlags::default();
405        assert_eq!(f.flags_schema, 1);
406        assert!(f.endpoint_url.is_none());
407        assert!(f.region_override.is_none());
408        assert_eq!(f.addressing_style, AddressingStyle::Auto);
409        assert_eq!(f.signature_version, SignatureVersion::V4);
410        assert_eq!(f.checksum_mode, ChecksumMode::Auto);
411        assert!(!f.accept_invalid_tls);
412        assert!(f.expect_continue);
413        assert!(!f.chunked_upload);
414        assert_eq!(f.bucket_name_validation, BucketNameValidation::Strict);
415        assert!(f.unknown.is_empty());
416    }
417
418    #[test]
419    fn serializes_and_deserializes_round_trip() {
420        let f = CompatFlags {
421            endpoint_url: Some("http://localhost:9000".to_string()),
422            addressing_style: AddressingStyle::Path,
423            accept_invalid_tls: true,
424            ..Default::default()
425        };
426
427        let json = serde_json::to_string(&f).unwrap();
428        let back: CompatFlags = serde_json::from_str(&json).unwrap();
429
430        assert_eq!(back.endpoint_url, f.endpoint_url);
431        assert_eq!(back.addressing_style, f.addressing_style);
432        assert_eq!(back.accept_invalid_tls, f.accept_invalid_tls);
433        assert_eq!(back.flags_schema, 1);
434    }
435
436    #[test]
437    fn unknown_fields_preserved_on_round_trip() {
438        // Simulate a JSON blob with an unknown future field.
439        let json = r#"{
440            "flagsSchema": 2,
441            "endpointUrl": "http://minio:9000",
442            "addressingStyle": "path",
443            "signatureVersion": "v4",
444            "checksumMode": "auto",
445            "acceptInvalidTls": false,
446            "expectContinue": true,
447            "chunkedUpload": false,
448            "bucketNameValidation": "strict",
449            "futureField": "preserved"
450        }"#;
451
452        let f: CompatFlags = serde_json::from_str(json).unwrap();
453        assert_eq!(f.flags_schema, 2);
454        assert_eq!(
455            f.unknown.get("futureField").and_then(|v| v.as_str()),
456            Some("preserved")
457        );
458
459        // Re-serializing must keep the unknown field.
460        let reser = serde_json::to_string(&f).unwrap();
461        assert!(
462            reser.contains("futureField"),
463            "unknown field lost after round-trip"
464        );
465    }
466
467    #[test]
468    fn camel_case_keys_in_json_output() {
469        let f = CompatFlags {
470            endpoint_url: Some("http://example.com".to_string()),
471            addressing_style: AddressingStyle::Path,
472            ..Default::default()
473        };
474        let json = serde_json::to_string(&f).unwrap();
475        assert!(
476            json.contains("endpointUrl"),
477            "expected camelCase key endpointUrl"
478        );
479        assert!(
480            json.contains("addressingStyle"),
481            "expected camelCase key addressingStyle"
482        );
483    }
484
485    #[test]
486    fn addressing_style_enum_serializes_to_snake_case() {
487        assert_eq!(
488            serde_json::to_string(&AddressingStyle::Virtual).unwrap(),
489            r#""virtual""#
490        );
491        assert_eq!(
492            serde_json::to_string(&AddressingStyle::Path).unwrap(),
493            r#""path""#
494        );
495        assert_eq!(
496            serde_json::to_string(&AddressingStyle::Auto).unwrap(),
497            r#""auto""#
498        );
499    }
500
501    #[test]
502    fn signature_version_enum_serializes() {
503        assert_eq!(
504            serde_json::to_string(&SignatureVersion::V4).unwrap(),
505            r#""v4""#
506        );
507        assert_eq!(
508            serde_json::to_string(&SignatureVersion::V2).unwrap(),
509            r#""v2""#
510        );
511    }
512
513    #[test]
514    fn checksum_mode_enum_serializes() {
515        assert_eq!(
516            serde_json::to_string(&ChecksumMode::Auto).unwrap(),
517            r#""auto""#
518        );
519        assert_eq!(
520            serde_json::to_string(&ChecksumMode::Disabled).unwrap(),
521            r#""disabled""#
522        );
523    }
524
525    #[test]
526    fn bucket_name_validation_enum_serializes() {
527        assert_eq!(
528            serde_json::to_string(&BucketNameValidation::Strict).unwrap(),
529            r#""strict""#
530        );
531        assert_eq!(
532            serde_json::to_string(&BucketNameValidation::Lax).unwrap(),
533            r#""lax""#
534        );
535    }
536
537    // -----------------------------------------------------------------------
538    // apply_to_s3_config_builder tests
539    // -----------------------------------------------------------------------
540
541    /// Build a minimal S3ConfigBuilder suitable for testing flag application.
542    ///
543    /// Region is left unset because `apply_to_s3_config_builder` does not
544    /// touch region (it is applied at the SDK loader level by the caller).
545    /// No network calls are made when building the resulting config.
546    fn base_builder() -> S3ConfigBuilder {
547        S3ConfigBuilder::new()
548    }
549
550    /// aws-sdk-s3 v1 doesn't expose getters on `Config` — only setters on
551    /// the Builder. We assert side-effects by Debug-formatting the resulting
552    /// Config and string-matching, which is the canonical pattern shown in
553    /// the SDK's own integration tests.
554    fn config_debug(config: &aws_sdk_s3::Config) -> String {
555        format!("{:?}", config)
556    }
557
558    #[test]
559    fn addressing_style_path_sets_force_path_style_true() {
560        let flags = CompatFlags {
561            addressing_style: AddressingStyle::Path,
562            ..Default::default()
563        };
564        let result = apply_to_s3_config_builder(&flags, base_builder());
565        let config = result.builder.build();
566        let dump = config_debug(&config);
567        assert!(
568            dump.contains("ForcePathStyle(true)"),
569            "Path addressing style must set force_path_style=true; got: {dump}"
570        );
571        assert!(
572            result.warnings.is_empty(),
573            "No warnings expected for path style"
574        );
575    }
576
577    #[test]
578    fn addressing_style_virtual_sets_force_path_style_false() {
579        let flags = CompatFlags {
580            addressing_style: AddressingStyle::Virtual,
581            ..Default::default()
582        };
583        let result = apply_to_s3_config_builder(&flags, base_builder());
584        let config = result.builder.build();
585        let dump = config_debug(&config);
586        assert!(
587            dump.contains("ForcePathStyle(false)"),
588            "Virtual addressing style must set force_path_style=false; got: {dump}"
589        );
590    }
591
592    #[test]
593    fn addressing_style_auto_leaves_force_path_style_unset() {
594        let flags = CompatFlags {
595            addressing_style: AddressingStyle::Auto,
596            ..Default::default()
597        };
598        let result = apply_to_s3_config_builder(&flags, base_builder());
599        let config = result.builder.build();
600        let dump = config_debug(&config);
601        // Auto means the SDK chooses; we must not set the flag.
602        assert!(
603            !dump.contains("ForcePathStyle"),
604            "Auto addressing style must leave force_path_style unset; got: {dump}"
605        );
606    }
607
608    #[test]
609    fn signature_version_v2_emits_warning_and_falls_back_to_v4() {
610        let flags = CompatFlags {
611            signature_version: SignatureVersion::V2,
612            ..Default::default()
613        };
614        let result = apply_to_s3_config_builder(&flags, base_builder());
615        assert!(
616            result.warnings.iter().any(|w| w.contains("V2")),
617            "Expected a warning containing 'V2'; got: {:?}",
618            result.warnings
619        );
620        // The builder itself does not need inspection — the warning is the
621        // observable signal and the SDK uses V4 by default.
622    }
623
624    #[test]
625    fn checksum_mode_disabled_sets_when_required() {
626        let flags = CompatFlags {
627            checksum_mode: ChecksumMode::Disabled,
628            ..Default::default()
629        };
630        let result = apply_to_s3_config_builder(&flags, base_builder());
631        let config = result.builder.build();
632        let dump = config_debug(&config);
633        assert!(
634            dump.contains("request_checksum_calculation: WhenRequired")
635                || dump.contains("WhenRequired"),
636            "Disabled checksum mode must set request_checksum_calculation=WhenRequired; got: {dump}"
637        );
638        assert!(
639            result.warnings.is_empty(),
640            "No warnings expected for checksum_mode=disabled"
641        );
642    }
643
644    #[test]
645    fn checksum_mode_auto_leaves_calculation_unset() {
646        let flags = CompatFlags {
647            checksum_mode: ChecksumMode::Auto,
648            ..Default::default()
649        };
650        let result = apply_to_s3_config_builder(&flags, base_builder());
651        let config = result.builder.build();
652        // Auto means SDK default; we must not override it.
653        assert!(
654            config.request_checksum_calculation().is_none(),
655            "Auto checksum mode must leave request_checksum_calculation unset"
656        );
657    }
658
659    #[test]
660    fn bucket_name_validation_lax_emits_warning() {
661        let flags = CompatFlags {
662            bucket_name_validation: BucketNameValidation::Lax,
663            ..Default::default()
664        };
665        let result = apply_to_s3_config_builder(&flags, base_builder());
666        // The flag is stored; the warning documents that SDK-level enforcement
667        // is not yet possible in aws-sdk-s3 v1.
668        assert!(
669            result
670                .warnings
671                .iter()
672                .any(|w| w.to_lowercase().contains("lax") || w.contains("bucket_name_validation")),
673            "Expected a warning about Lax bucket validation; got: {:?}",
674            result.warnings
675        );
676    }
677
678    #[test]
679    fn chunked_upload_true_emits_warning() {
680        let flags = CompatFlags {
681            chunked_upload: true,
682            ..Default::default()
683        };
684        let result = apply_to_s3_config_builder(&flags, base_builder());
685        assert!(
686            result.warnings.iter().any(|w| w.contains("chunked_upload")),
687            "Expected a warning about chunked_upload; got: {:?}",
688            result.warnings
689        );
690    }
691
692    // -----------------------------------------------------------------------
693    // Forward-compat tests
694    // -----------------------------------------------------------------------
695
696    /// (a) Unknown flags in `CompatFlags::unknown` must emit one warning per
697    /// key and must NOT cause an error.
698    #[test]
699    fn unknown_flags_emit_per_key_warning_and_do_not_error() {
700        let json = r#"{
701            "flagsSchema": 1,
702            "endpointUrl": "http://minio:9000",
703            "addressingStyle": "path",
704            "signatureVersion": "v4",
705            "checksumMode": "auto",
706            "acceptInvalidTls": false,
707            "expectContinue": true,
708            "chunkedUpload": false,
709            "bucketNameValidation": "strict",
710            "futureFlag1": "value1",
711            "futureFlag2": 42
712        }"#;
713        let flags: CompatFlags = serde_json::from_str(json).unwrap();
714        assert_eq!(flags.unknown.len(), 2, "should have 2 unknown keys");
715
716        let result = apply_to_s3_config_builder(&flags, base_builder());
717
718        // Must return Ok (function signature is infallible — no Err path).
719        // Assert one warning per unknown key.
720        let unknown_warnings: Vec<_> = result
721            .warnings
722            .iter()
723            .filter(|w| w.contains("Unknown compat flag"))
724            .collect();
725        assert_eq!(
726            unknown_warnings.len(),
727            2,
728            "Expected one warning per unknown flag; got: {:?}",
729            result.warnings
730        );
731        assert!(
732            unknown_warnings.iter().any(|w| w.contains("futureFlag1")),
733            "Warning must name the unknown key 'futureFlag1'"
734        );
735        assert!(
736            unknown_warnings.iter().any(|w| w.contains("futureFlag2")),
737            "Warning must name the unknown key 'futureFlag2'"
738        );
739    }
740
741    /// (b) `flags_schema != 1` must emit a schema-mismatch warning but still
742    /// apply the known fields.  We verify via `endpoint_url` + path style.
743    #[test]
744    fn schema_mismatch_emits_warning_and_still_applies_known_flags() {
745        // Simulate a flags struct written by schema v99.
746        let flags = CompatFlags {
747            flags_schema: 99,
748            endpoint_url: Some("http://minio:9000".to_string()),
749            addressing_style: AddressingStyle::Path,
750            ..Default::default()
751        };
752
753        let result = apply_to_s3_config_builder(&flags, base_builder());
754
755        // Must emit a schema-mismatch warning.
756        assert!(
757            result
758                .warnings
759                .iter()
760                .any(|w| w.contains("schema mismatch")),
761            "Expected a 'schema mismatch' warning; got: {:?}",
762            result.warnings
763        );
764
765        // Known flags (addressing_style=Path) must still be applied.
766        let config = result.builder.build();
767        let dump = config_debug(&config);
768        assert!(
769            dump.contains("ForcePathStyle(true)"),
770            "addressing_style=Path must still be applied despite schema mismatch; got: {dump}"
771        );
772        // endpoint_url is applied by the caller (SDK loader), not by this
773        // function — no assertion needed on config.endpoint_url() here.
774    }
775}