3434 S3PathResolver
3535from awscli .customizations .utils import uni_print
3636from awscli .customizations .s3 .syncstrategy .base import MissingFileSync , \
37- SizeAndLastModifiedSync , NeverSync
37+ SizeAndLastModifiedSync , NeverSync , AlwaysSync
38+ from awscli .customizations .s3 .syncstrategy .caseconflict import CaseConflictSync
3839from awscli .customizations .s3 import transferconfig
3940from awscli .utils import resolve_v2_debug_mode
4041
482483 )
483484}
484485
486+ CASE_CONFLICT = {
487+ 'name' : 'case-conflict' ,
488+ 'choices' : [
489+ 'ignore' ,
490+ 'skip' ,
491+ 'warn' ,
492+ 'error' ,
493+ ],
494+ 'default' : 'ignore' ,
495+ 'help_text' : (
496+ "Configures behavior when attempting to download multiple objects "
497+ "whose keys differ only by case, which can cause undefined behavior "
498+ "on case-insensitive filesystems. "
499+ "This parameter only applies for commands that perform multiple S3 "
500+ "to local downloads. "
501+ f"See <a href='{ CaseConflictSync .DOC_URI } '>Handling case "
502+ "conflicts</a> for details. Valid values are: "
503+ "<ul>"
504+ "<li>``error`` - Raise an error and abort downloads.</li>"
505+ "<li>``warn`` - Emit a warning and download the object.</li>"
506+ "<li>``skip`` - Skip downloading the object.</li>"
507+ "<li>``ignore`` - The default value. Ignore the conflict and "
508+ "download the object.</li>"
509+ "</ul>"
510+ ),
511+ }
512+
485513TRANSFER_ARGS = [DRYRUN , QUIET , INCLUDE , EXCLUDE , ACL ,
486514 FOLLOW_SYMLINKS , NO_FOLLOW_SYMLINKS , NO_GUESS_MIME_TYPE ,
487515 SSE , SSE_C , SSE_C_KEY , SSE_KMS_KEY_ID , SSE_C_COPY_SOURCE ,
@@ -807,7 +835,8 @@ class CpCommand(S3TransferCommand):
807835 "or <S3Uri> <S3Uri>"
808836 ARG_TABLE = [{'name' : 'paths' , 'nargs' : 2 , 'positional_arg' : True ,
809837 'synopsis' : USAGE }] + TRANSFER_ARGS + \
810- [METADATA , METADATA_DIRECTIVE , EXPECTED_SIZE , RECURSIVE ]
838+ [METADATA , METADATA_DIRECTIVE , EXPECTED_SIZE , RECURSIVE ,
839+ CASE_CONFLICT ]
811840
812841
813842class MvCommand (S3TransferCommand ):
@@ -817,7 +846,8 @@ class MvCommand(S3TransferCommand):
817846 "or <S3Uri> <S3Uri>"
818847 ARG_TABLE = [{'name' : 'paths' , 'nargs' : 2 , 'positional_arg' : True ,
819848 'synopsis' : USAGE }] + TRANSFER_ARGS + \
820- [METADATA , METADATA_DIRECTIVE , RECURSIVE , VALIDATE_SAME_S3_PATHS ]
849+ [METADATA , METADATA_DIRECTIVE , RECURSIVE , VALIDATE_SAME_S3_PATHS ,
850+ CASE_CONFLICT ]
821851
822852
823853class RmCommand (S3TransferCommand ):
@@ -839,7 +869,7 @@ class SyncCommand(S3TransferCommand):
839869 "<LocalPath> or <S3Uri> <S3Uri>"
840870 ARG_TABLE = [{'name' : 'paths' , 'nargs' : 2 , 'positional_arg' : True ,
841871 'synopsis' : USAGE }] + TRANSFER_ARGS + \
842- [METADATA , METADATA_DIRECTIVE ]
872+ [METADATA , METADATA_DIRECTIVE , CASE_CONFLICT ]
843873
844874
845875class MbCommand (S3Command ):
@@ -1004,7 +1034,16 @@ def choose_sync_strategies(self):
10041034 # Set the default strategies.
10051035 sync_strategies ['file_at_src_and_dest_sync_strategy' ] = \
10061036 SizeAndLastModifiedSync ()
1007- sync_strategies ['file_not_at_dest_sync_strategy' ] = MissingFileSync ()
1037+ if self ._should_handle_case_conflicts ():
1038+ sync_strategies ['file_not_at_dest_sync_strategy' ] = (
1039+ CaseConflictSync (
1040+ on_case_conflict = self .parameters ['case_conflict' ]
1041+ )
1042+ )
1043+ else :
1044+ sync_strategies ['file_not_at_dest_sync_strategy' ] = (
1045+ MissingFileSync ()
1046+ )
10081047 sync_strategies ['file_not_at_src_sync_strategy' ] = NeverSync ()
10091048
10101049 # Determine what strategies to override if any.
@@ -1138,6 +1177,12 @@ def run(self):
11381177 'filters' : [create_filter (self .parameters )],
11391178 'file_info_builder' : [file_info_builder ],
11401179 's3_handler' : [s3_transfer_handler ]}
1180+ if self ._should_handle_case_conflicts ():
1181+ self ._handle_case_conflicts (
1182+ command_dict ,
1183+ rev_files ,
1184+ rev_generator ,
1185+ )
11411186 elif self .cmd == 'rm' :
11421187 command_dict = {'setup' : [files ],
11431188 'file_generator' : [file_generator ],
@@ -1150,6 +1195,12 @@ def run(self):
11501195 'filters' : [create_filter (self .parameters )],
11511196 'file_info_builder' : [file_info_builder ],
11521197 's3_handler' : [s3_transfer_handler ]}
1198+ if self ._should_handle_case_conflicts ():
1199+ self ._handle_case_conflicts (
1200+ command_dict ,
1201+ rev_files ,
1202+ rev_generator ,
1203+ )
11531204
11541205 files = command_dict ['setup' ]
11551206 while self .instructions :
@@ -1215,6 +1266,74 @@ def _map_sse_c_params(self, request_parameters, paths_type):
12151266 }
12161267 )
12171268
1269+ def _should_handle_case_conflicts (self ):
1270+ return (
1271+ self .cmd in {'sync' , 'cp' , 'mv' }
1272+ and self .parameters .get ('paths_type' ) == 's3local'
1273+ and self .parameters ['case_conflict' ] != 'ignore'
1274+ and self .parameters .get ('dir_op' )
1275+ )
1276+
1277+ def _handle_case_conflicts (self , command_dict , rev_files , rev_generator ):
1278+ # Objects are not returned in lexicographical order when
1279+ # operated on S3 Express directory buckets. This is required
1280+ # for sync operations to behave correctly, which is what
1281+ # recursive copies and moves fall back to so potential case
1282+ # conflicts can be detected and handled.
1283+ if not is_s3express_bucket (
1284+ split_s3_bucket_key (self .parameters ['src' ])[0 ]
1285+ ):
1286+ self ._modify_instructions_for_case_conflicts (
1287+ command_dict , rev_files , rev_generator
1288+ )
1289+ return
1290+ # `skip` and `error` are not valid choices in this case because
1291+ # it's not possible to detect case conflicts.
1292+ if self .parameters ['case_conflict' ] not in {'ignore' , 'warn' }:
1293+ raise ValueError (
1294+ f"`{ self .parameters ['case_conflict' ]} ` is not a valid value "
1295+ "for `--case-conflict` when operating on S3 Express "
1296+ "directory buckets. Valid values: `warn`, `ignore`."
1297+ )
1298+ msg = (
1299+ "warning: Recursive copies/moves from an S3 Express "
1300+ "directory bucket to a case-insensitive local filesystem "
1301+ "may result in undefined behavior if there are "
1302+ "S3 object key names that differ only by case. To disable "
1303+ "this warning, set the `--case-conflict` parameter to `ignore`. "
1304+ f"For more information, see { CaseConflictSync .DOC_URI } ."
1305+ )
1306+ uni_print (msg , sys .stderr )
1307+
1308+ def _modify_instructions_for_case_conflicts (
1309+ self , command_dict , rev_files , rev_generator
1310+ ):
1311+ # Command will perform recursive S3 to local downloads.
1312+ # Checking for potential case conflicts requires knowledge
1313+ # of local files. Instead of writing a separate validation
1314+ # mechanism for recursive downloads, we modify the instructions
1315+ # to mimic a sync command.
1316+ sync_strategies = {
1317+ # Local filename exists with exact case match. Always sync
1318+ # because it's a copy operation.
1319+ 'file_at_src_and_dest_sync_strategy' : AlwaysSync (),
1320+ # Local filename either doesn't exist or differs only by case.
1321+ # Let `CaseConflictSync` determine which it is and handle it
1322+ # according to configured `--case-conflict` parameter.
1323+ 'file_not_at_dest_sync_strategy' : CaseConflictSync (
1324+ on_case_conflict = self .parameters ['case_conflict' ]
1325+ ),
1326+ # Copy is one-way so never sync if not at source.
1327+ 'file_not_at_src_sync_strategy' : NeverSync (),
1328+ }
1329+ command_dict ['setup' ].append (rev_files )
1330+ command_dict ['file_generator' ].append (rev_generator )
1331+ command_dict ['filters' ].append (create_filter (self .parameters ))
1332+ command_dict ['comparator' ] = [Comparator (** sync_strategies )]
1333+ self .instructions .insert (
1334+ self .instructions .index ('file_info_builder' ), 'comparator'
1335+ )
1336+
12181337
12191338class CommandParameters (object ):
12201339 """
0 commit comments