Skip to content

Commit 960680b

Browse files
committed
add case sensitivity check to download directory command
1 parent 9ade4e1 commit 960680b

File tree

5 files changed

+303
-1
lines changed

5 files changed

+303
-1
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"services": [
3+
{
4+
"serviceName": "S3",
5+
"type": "minor",
6+
"changeLogMessages": [
7+
"BREAKING CHANGE: A security fix has been applied to the Transfer Utility Download Directory command which now checks if the directory is case-sensitive before proceeding with the download. This prevents potential overwriting of files in case-insensitive file systems. If you would like to revert to the previous behavior, you can set the 'SkipDirectoryCaseSensitivityCheck' property on the 'TransferUtilityConfig' to true."
8+
]
9+
}
10+
]
11+
}

sdk/src/Services/S3/Custom/Transfer/Internal/_bcl+netstandard/DownloadDirectoryCommand.cs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,13 @@ public override async Task<TransferUtilityDownloadDirectoryResponse> ExecuteAsyn
6666

6767
// Step 3: Filter to actual files (exclude directory markers)
6868
var objectsToDownload = FilterObjectsToDownload(s3Objects);
69-
69+
7070
// Step 4: Setup resources and execute downloads
7171
using (var resources = CreateDownloadResources(cancellationToken))
7272
{
73+
await CheckIfKeysDifferByCase(objectsToDownload, prefixLength, resources.InternalCancellationTokenSource)
74+
.ConfigureAwait(false);
75+
7376
await ExecuteParallelDownloadsAsync(
7477
objectsToDownload,
7578
prefixLength,
@@ -93,6 +96,63 @@ await ExecuteParallelDownloadsAsync(
9396
}
9497
}
9598

99+
/// <summary>
100+
/// Checks if any S3 object keys differ only by case.
101+
/// On case-insensitive file systems, this can lead to conflicts where objects can be overwritten.
102+
/// If such conflicts are found, the configured failure policy is applied.
103+
/// </summary>
104+
private async Task CheckIfKeysDifferByCase(List<S3Object> s3Objects, int prefixLength, CancellationTokenSource cancellationTokenSource)
105+
{
106+
if (_config.SkipDirectoryCaseSensitivityCheck)
107+
{
108+
_logger.DebugFormat("DownloadDirectoryCommand.CheckIfKeysDifferByCase: Skipping case sensitivity check as per configuration.");
109+
return;
110+
}
111+
var isCaseSensitive = FileSystemHelper.IsDirectoryCaseSensitive(this._request.LocalDirectory);
112+
if (isCaseSensitive)
113+
{
114+
_logger.DebugFormat("DownloadDirectoryCommand.CheckIfKeysDifferByCase: Target directory is case-sensitive, skipping check.");
115+
return;
116+
}
117+
118+
var hashSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
119+
foreach (var s3Object in s3Objects)
120+
{
121+
// Create failure callback
122+
Action<Exception> onFailure = (ex) =>
123+
{
124+
var downloadRequest = ConstructTransferUtilityDownloadRequest(s3Object, prefixLength);
125+
this._request.OnRaiseObjectDownloadFailedEvent(
126+
new ObjectDownloadFailedEventArgs(
127+
this._request,
128+
downloadRequest,
129+
ex));
130+
};
131+
132+
await _failurePolicy.ExecuteAsync(
133+
() =>
134+
{
135+
string currentKey = s3Object.Key;
136+
137+
// Check the key against the hash set for case-insensitive duplicates
138+
if (!hashSet.Add(currentKey))
139+
{
140+
// A case-insensitive match was found.
141+
// Handle the conflict according to the configured failure policy.
142+
throw new AmazonS3Exception(
143+
"The S3 directory contains multiple objects whose keys differ only by case. " +
144+
"This can lead to conflicts when downloading to a case-insensitive file system. " +
145+
$"The key {currentKey} conflicts with another object that differs only by case.");
146+
}
147+
148+
return Task.CompletedTask;
149+
},
150+
onFailure,
151+
cancellationTokenSource
152+
).ConfigureAwait(false);
153+
}
154+
}
155+
96156
/// <summary>
97157
/// Encapsulates disposable resources used during directory download.
98158
/// </summary>

sdk/src/Services/S3/Custom/Transfer/TransferUtilityConfig.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public partial class TransferUtilityConfig
4949
public TransferUtilityConfig()
5050
{
5151
this.ConcurrentServiceRequests = 10;
52+
this.SkipDirectoryCaseSensitivityCheck = false;
5253
}
5354

5455
/// <summary>
@@ -81,5 +82,12 @@ public int ConcurrentServiceRequests
8182
this._concurrentServiceRequests = value;
8283
}
8384
}
85+
86+
/// <summary>
87+
/// When true, disables directory case-sensitivity checks that are used to detect
88+
/// potential filename collisions (for example, S3 keys that differ only by case
89+
/// on a case-insensitive filesystem). The default is false.
90+
/// </summary>
91+
public bool SkipDirectoryCaseSensitivityCheck { get; set; }
8492
}
8593
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using System;
2+
using System.IO;
3+
4+
namespace Amazon.S3.Util
5+
{
6+
internal static class FileSystemHelper
7+
{
8+
/// <summary>
9+
/// Determines if the specified directory path is case-sensitive.
10+
/// </summary>
11+
/// <param name="directoryPath">The full path to the directory to check.</param>
12+
/// <remarks>
13+
/// Case-sensitivity can vary by directory or volume depending on the platform and configuration:
14+
/// <list type="bullet">
15+
/// <item>
16+
/// <description>
17+
/// On Windows, NTFS is case-preserving but usually case-insensitive; however, case-sensitivity
18+
/// can be enabled per-directory (for example via WSL tooling), so two directories on the same
19+
/// volume may behave differently.
20+
/// </description>
21+
/// </item>
22+
/// <item>
23+
/// <description>
24+
/// On Unix-like systems and macOS, different mount points or volumes can have different
25+
/// case-sensitivity semantics.
26+
/// </description>
27+
/// </item>
28+
/// </list>
29+
/// Because of this, the check is performed against the actual target directory instead of a
30+
/// global temporary location. This ensures we detect collisions (such as S3 keys that differ
31+
/// only by case) according to the behavior of the specific filesystem where we will create files.
32+
/// </remarks>
33+
/// <returns>
34+
/// True if the file system at the path is case-sensitive; False if case-insensitive.
35+
/// </returns>
36+
/// <exception cref="ArgumentNullException">If the directoryPath is null or empty.</exception>
37+
/// <exception cref="DirectoryNotFoundException">If the directory does not exist.</exception>
38+
public static bool IsDirectoryCaseSensitive(string directoryPath)
39+
{
40+
if (string.IsNullOrEmpty(directoryPath))
41+
{
42+
throw new ArgumentNullException(nameof(directoryPath));
43+
}
44+
45+
if (!Directory.Exists(directoryPath))
46+
{
47+
throw new DirectoryNotFoundException($"The directory '{directoryPath}' does not exist.");
48+
}
49+
50+
#pragma warning disable CA1308 // Normalize strings to uppercase
51+
string tempFileLower = Path.Combine(directoryPath, Guid.NewGuid().ToString().ToLowerInvariant() + ".tmp");
52+
#pragma warning restore CA1308 // Normalize strings to uppercase
53+
string tempFileUpper = Path.Combine(directoryPath, Path.GetFileName(tempFileLower).ToUpperInvariant());
54+
bool isCaseSensitive = true; // Assume sensitive by default
55+
56+
#pragma warning disable CA1031 // Do not catch general exception types
57+
try
58+
{
59+
// Create the file with a lowercase name
60+
using (File.Create(tempFileLower)) { }
61+
62+
// Check if the uppercase version of the file exists.
63+
// If it does, the file system is case-insensitive.
64+
if (File.Exists(tempFileUpper))
65+
{
66+
isCaseSensitive = false;
67+
}
68+
}
69+
catch (Exception)
70+
{
71+
// Fallback assumption is that the file system is case-sensitive
72+
isCaseSensitive = true;
73+
}
74+
finally
75+
{
76+
try
77+
{
78+
// Ensure temporary files are cleaned up in all cases
79+
if (File.Exists(tempFileLower)) File.Delete(tempFileLower);
80+
if (File.Exists(tempFileUpper)) File.Delete(tempFileUpper);
81+
}
82+
catch (Exception) { }
83+
}
84+
#pragma warning restore CA1031 // Do not catch general exception types
85+
86+
return isCaseSensitive;
87+
}
88+
}
89+
}

sdk/test/Services/S3/UnitTests/Custom/DownloadDirectoryCommandTests.cs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Amazon.S3.Model;
33
using Amazon.S3.Transfer;
44
using Amazon.S3.Transfer.Internal;
5+
using Amazon.S3.Util;
56
using Microsoft.VisualStudio.TestTools.UnitTesting;
67
using Moq;
78
using System;
@@ -1009,6 +1010,139 @@ public async Task ExecuteAsync_SequentialMode_DownloadsOneAtATime()
10091010

10101011
#endregion
10111012

1013+
#region Case-Insensitive Key Conflict Tests
1014+
1015+
[TestMethod]
1016+
public async Task ExecuteAsync_CaseInsensitiveKeyConflict_AbortOnFailure_ThrowsAndNoFilesDownloaded()
1017+
{
1018+
var request = CreateDownloadDirectoryRequest();
1019+
var isCaseSensitive = FileSystemHelper.IsDirectoryCaseSensitive(request.LocalDirectory);
1020+
if (isCaseSensitive)
1021+
{
1022+
return;
1023+
}
1024+
request.FailurePolicy = FailurePolicy.AbortOnFailure;
1025+
1026+
var listResponse = new ListObjectsResponse
1027+
{
1028+
S3Objects = new List<S3Object>
1029+
{
1030+
new S3Object { Key = "prefix/File.txt", Size = 10 },
1031+
new S3Object { Key = "prefix/file.txt", Size = 20 }
1032+
}
1033+
};
1034+
1035+
_mockS3Client.Setup(c => c.ListObjectsAsync(
1036+
It.IsAny<ListObjectsRequest>(),
1037+
It.IsAny<CancellationToken>()))
1038+
.ReturnsAsync(listResponse);
1039+
1040+
var command = new DownloadDirectoryCommand(_mockS3Client.Object, request, _config, useMultipartDownload: false);
1041+
1042+
try
1043+
{
1044+
await command.ExecuteAsync(CancellationToken.None);
1045+
Assert.Fail("Expected an exception due to case-insensitive key conflict.");
1046+
}
1047+
catch (Exception ex)
1048+
{
1049+
StringAssert.Contains(ex.Message, "conflict", "Exception message should indicate a key path conflict.");
1050+
}
1051+
}
1052+
1053+
[TestMethod]
1054+
public async Task ExecuteAsync_CaseInsensitiveKeyConflict_ContinueOnFailure_ReportsFailureInResponse()
1055+
{
1056+
var request = CreateDownloadDirectoryRequest();
1057+
var isCaseSensitive = FileSystemHelper.IsDirectoryCaseSensitive(request.LocalDirectory);
1058+
if (isCaseSensitive)
1059+
{
1060+
return;
1061+
}
1062+
request.FailurePolicy = FailurePolicy.ContinueOnFailure;
1063+
1064+
var listResponse = new ListObjectsResponse
1065+
{
1066+
S3Objects = new List<S3Object>
1067+
{
1068+
new S3Object { Key = "prefix/File.txt", Size = 10 },
1069+
new S3Object { Key = "prefix/file.txt", Size = 20 }
1070+
}
1071+
};
1072+
1073+
_mockS3Client.Setup(c => c.ListObjectsAsync(
1074+
It.IsAny<ListObjectsRequest>(),
1075+
It.IsAny<CancellationToken>()))
1076+
.ReturnsAsync(listResponse);
1077+
1078+
var command = new DownloadDirectoryCommand(_mockS3Client.Object, request, _config, useMultipartDownload: false);
1079+
1080+
var response = await command.ExecuteAsync(CancellationToken.None);
1081+
1082+
Assert.IsNotNull(response, "Response should not be null.");
1083+
Assert.AreEqual(0, response.ObjectsDownloaded, "No files should be downloaded when all conflicted.");
1084+
Assert.IsTrue(response.ObjectsFailed >= 1, "At least one failure should be recorded.");
1085+
Assert.IsNotNull(response.Errors, "Errors collection should be populated.");
1086+
Assert.IsTrue(response.Errors.Count >= 1, "Errors collection should contain at least one error.");
1087+
Assert.IsTrue(response.Result == DirectoryResult.Failure ||
1088+
response.Result == DirectoryResult.PartialSuccess,
1089+
"Result should indicate failure or partial success due to conflicts.");
1090+
}
1091+
1092+
[TestMethod]
1093+
public async Task ExecuteAsync_CaseInsensitiveKeyConflict_SkipCaseCheck_AllowsDownloads()
1094+
{
1095+
var request = CreateDownloadDirectoryRequest();
1096+
var isCaseSensitive = FileSystemHelper.IsDirectoryCaseSensitive(request.LocalDirectory);
1097+
if (isCaseSensitive)
1098+
{
1099+
// This test validates behavior only on case-insensitive filesystems.
1100+
// On case-sensitive systems, there is no conflict to detect.
1101+
return;
1102+
}
1103+
1104+
request.FailurePolicy = FailurePolicy.AbortOnFailure;
1105+
1106+
var listResponse = new ListObjectsResponse
1107+
{
1108+
S3Objects = new List<S3Object>
1109+
{
1110+
new S3Object { Key = "prefix/File.txt", Size = 10 },
1111+
new S3Object { Key = "prefix/file.txt", Size = 20 }
1112+
}
1113+
};
1114+
1115+
_mockS3Client.Setup(c => c.ListObjectsAsync(
1116+
It.IsAny<ListObjectsRequest>(),
1117+
It.IsAny<CancellationToken>()))
1118+
.ReturnsAsync(listResponse);
1119+
1120+
SetupGetObjectForFile("prefix/File.txt", 10, setupForMultipart: false);
1121+
SetupGetObjectForFile("prefix/file.txt", 20, setupForMultipart: false);
1122+
1123+
// Enable skipping of directory case-sensitivity checks so that the
1124+
// conflict detection logic is bypassed.
1125+
var config = new TransferUtilityConfig
1126+
{
1127+
ConcurrentServiceRequests = 4,
1128+
SkipDirectoryCaseSensitivityCheck = true
1129+
};
1130+
1131+
var command = new DownloadDirectoryCommand(_mockS3Client.Object, request, config, useMultipartDownload: false);
1132+
1133+
// Act: with the skip flag enabled, the operation should not throw a
1134+
// conflict exception and should attempt to download objects.
1135+
var response = await command.ExecuteAsync(CancellationToken.None);
1136+
1137+
Assert.IsNotNull(response, "Response should not be null when skipping case check.");
1138+
// Behavior of how many files succeed may vary by implementation
1139+
// (last one wins on overwrite), but we at least assert that no
1140+
// conflict exception prevented completion.
1141+
Assert.IsTrue(response.ObjectsDownloaded >= 1, "At least one object should have been downloaded when skipping case checks.");
1142+
}
1143+
1144+
#endregion
1145+
10121146
#region Helper Methods
10131147

10141148
private TransferUtilityDownloadDirectoryRequest CreateDownloadDirectoryRequest(

0 commit comments

Comments
 (0)