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}