From d03c23030ba89b2b47e15d07064776a27b335493 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Mon, 10 Aug 2026 10:18:54 -0700 Subject: [PATCH 1/2] javadoc edits, removing double encode, adding test class --- .../azure-storage-file-datalake/CHANGELOG.md | 6 + .../DataLakeDirectoryAsyncClient.java | 8 +- .../datalake/DataLakeDirectoryClient.java | 5 +- .../datalake/DataLakeFileAsyncClient.java | 8 +- .../file/datalake/DataLakeFileClient.java | 4 +- .../DataLakeFileSystemAsyncClient.java | 48 +-- .../datalake/DataLakeFileSystemClient.java | 48 +-- .../datalake/DataLakePathClientBuilder.java | 4 +- .../DataLakePathNameEncodingTests.java | 406 ++++++++++++++++++ 9 files changed, 472 insertions(+), 65 deletions(-) create mode 100644 sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakePathNameEncodingTests.java diff --git a/sdk/storage/azure-storage-file-datalake/CHANGELOG.md b/sdk/storage/azure-storage-file-datalake/CHANGELOG.md index 16be07ade66c..d590365379d7 100644 --- a/sdk/storage/azure-storage-file-datalake/CHANGELOG.md +++ b/sdk/storage/azure-storage-file-datalake/CHANGELOG.md @@ -7,8 +7,14 @@ ### Breaking Changes ### Bugs Fixed +- Fixed a bug where the client returned by `rename`, `renameWithResponse`, and `undeletePath` URL-encoded the path + name a second time, causing subsequent requests made through that client to target a double-encoded path when the + name contained characters such as `%`, `#`, or a space. ### Other Changes +- Corrected documentation on `DataLakeFileSystemClient`/`DataLakeFileSystemAsyncClient` path client getters and on + `DataLakePathClientBuilder.pathName(String)`, which incorrectly instructed callers to pass a URL-encoded path name. + Path names have been used verbatim since 12.22.0 and are percent-encoded by the client when the request URL is built. ## 12.29.0-beta.1 (2026-07-28) diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeDirectoryAsyncClient.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeDirectoryAsyncClient.java index d2e3dbe9e637..7bbfe9998f7a 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeDirectoryAsyncClient.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeDirectoryAsyncClient.java @@ -21,7 +21,6 @@ import com.azure.storage.blob.BlobContainerAsyncClient; import com.azure.storage.blob.specialized.BlockBlobAsyncClient; import com.azure.storage.blob.specialized.SpecializedBlobClientBuilder; -import com.azure.storage.common.Utility; import com.azure.storage.common.implementation.Constants; import com.azure.storage.file.datalake.implementation.models.CpkInfo; import com.azure.storage.file.datalake.implementation.models.FileSystemsListPathsHeaders; @@ -91,10 +90,9 @@ public final class DataLakeDirectoryAsyncClient extends DataLakePathAsyncClient DataLakeDirectoryAsyncClient(DataLakePathAsyncClient dataLakePathAsyncClient) { super(dataLakePathAsyncClient.getHttpPipeline(), dataLakePathAsyncClient.getAccountUrl(), dataLakePathAsyncClient.getServiceVersion(), dataLakePathAsyncClient.getAccountName(), - dataLakePathAsyncClient.getFileSystemName(), Utility.urlEncode(dataLakePathAsyncClient.pathName), - PathResourceType.DIRECTORY, dataLakePathAsyncClient.getBlockBlobAsyncClient(), - dataLakePathAsyncClient.getSasToken(), dataLakePathAsyncClient.getCpkInfo(), - dataLakePathAsyncClient.isTokenCredentialAuthenticated()); + dataLakePathAsyncClient.getFileSystemName(), dataLakePathAsyncClient.pathName, PathResourceType.DIRECTORY, + dataLakePathAsyncClient.getBlockBlobAsyncClient(), dataLakePathAsyncClient.getSasToken(), + dataLakePathAsyncClient.getCpkInfo(), dataLakePathAsyncClient.isTokenCredentialAuthenticated()); } /** diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeDirectoryClient.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeDirectoryClient.java index e14112f34bee..1ab263a8b8c4 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeDirectoryClient.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeDirectoryClient.java @@ -17,7 +17,6 @@ import com.azure.core.util.Context; import com.azure.storage.blob.specialized.BlockBlobClient; import com.azure.storage.blob.specialized.SpecializedBlobClientBuilder; -import com.azure.storage.common.Utility; import com.azure.storage.common.implementation.Constants; import com.azure.storage.common.implementation.StorageImplUtils; import com.azure.storage.file.datalake.implementation.models.CpkInfo; @@ -77,8 +76,8 @@ public class DataLakeDirectoryClient extends DataLakePathClient { super(dataLakePathClient.dataLakePathAsyncClient, dataLakePathClient.blockBlobClient, dataLakePathClient.getHttpPipeline(), dataLakePathClient.getAccountUrl(), dataLakePathClient.getServiceVersion(), dataLakePathClient.getAccountName(), - dataLakePathClient.getFileSystemName(), Utility.urlEncode(dataLakePathClient.pathName), - PathResourceType.DIRECTORY, dataLakePathClient.getSasToken(), dataLakePathClient.getCpkInfo(), + dataLakePathClient.getFileSystemName(), dataLakePathClient.pathName, PathResourceType.DIRECTORY, + dataLakePathClient.getSasToken(), dataLakePathClient.getCpkInfo(), dataLakePathClient.isTokenCredentialAuthenticated()); this.dataLakeDirectoryAsyncClient = new DataLakeDirectoryAsyncClient(dataLakePathClient.dataLakePathAsyncClient); diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileAsyncClient.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileAsyncClient.java index 372ffb297f34..b68e5027f8a1 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileAsyncClient.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileAsyncClient.java @@ -24,7 +24,6 @@ import com.azure.storage.blob.options.BlobDownloadToFileOptions; import com.azure.storage.blob.specialized.BlockBlobAsyncClient; import com.azure.storage.common.ParallelTransferOptions; -import com.azure.storage.common.Utility; import com.azure.storage.common.implementation.BufferAggregator; import com.azure.storage.common.implementation.BufferStagingArea; import com.azure.storage.common.implementation.Constants; @@ -127,10 +126,9 @@ public class DataLakeFileAsyncClient extends DataLakePathAsyncClient { DataLakeFileAsyncClient(DataLakePathAsyncClient pathAsyncClient) { super(pathAsyncClient.getHttpPipeline(), pathAsyncClient.getAccountUrl(), pathAsyncClient.getServiceVersion(), - pathAsyncClient.getAccountName(), pathAsyncClient.getFileSystemName(), - Utility.urlEncode(pathAsyncClient.pathName), PathResourceType.FILE, - pathAsyncClient.getBlockBlobAsyncClient(), pathAsyncClient.getSasToken(), pathAsyncClient.getCpkInfo(), - pathAsyncClient.isTokenCredentialAuthenticated()); + pathAsyncClient.getAccountName(), pathAsyncClient.getFileSystemName(), pathAsyncClient.pathName, + PathResourceType.FILE, pathAsyncClient.getBlockBlobAsyncClient(), pathAsyncClient.getSasToken(), + pathAsyncClient.getCpkInfo(), pathAsyncClient.isTokenCredentialAuthenticated()); } /** diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileClient.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileClient.java index eb35795bd709..c343cd636f6b 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileClient.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileClient.java @@ -117,8 +117,8 @@ public class DataLakeFileClient extends DataLakePathClient { super(dataLakePathClient.dataLakePathAsyncClient, dataLakePathClient.getBlockBlobClient(), dataLakePathClient.getHttpPipeline(), dataLakePathClient.getAccountUrl(), dataLakePathClient.getServiceVersion(), dataLakePathClient.getAccountName(), - dataLakePathClient.getFileSystemName(), Utility.urlEncode(dataLakePathClient.pathName), - PathResourceType.FILE, dataLakePathClient.getSasToken(), dataLakePathClient.getCpkInfo(), + dataLakePathClient.getFileSystemName(), dataLakePathClient.pathName, PathResourceType.FILE, + dataLakePathClient.getSasToken(), dataLakePathClient.getCpkInfo(), dataLakePathClient.isTokenCredentialAuthenticated()); this.dataLakeFileAsyncClient = new DataLakeFileAsyncClient(dataLakePathClient.dataLakePathAsyncClient); } diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemAsyncClient.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemAsyncClient.java index d57b4515ab0d..56973be0ac8a 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemAsyncClient.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemAsyncClient.java @@ -167,8 +167,8 @@ public class DataLakeFileSystemAsyncClient { * * * - * @param fileName A {@code String} representing the name of the file. If the path name contains special characters, - * pass in the url encoded version of the path name. + * @param fileName A {@code String} representing the name of the file. Pass the name unencoded; the client + * percent-encodes it when building the request URL. * @return A new {@link DataLakeFileAsyncClient} object which references the file with the specified name in this * file system. */ @@ -197,8 +197,8 @@ public DataLakeFileAsyncClient getFileAsyncClient(String fileName) { * * * - * @param directoryName A {@code String} representing the name of the directory. If the path name contains special - * characters, pass in the url encoded version of the path name. + * @param directoryName A {@code String} representing the name of the directory. Pass the name unencoded; the client + * percent-encodes it when building the request URL. * @return A new {@link DataLakeDirectoryAsyncClient} object which references the directory with the specified name * in this file system. */ @@ -1220,8 +1220,8 @@ public Mono> deleteFileIfExistsWithResponse(String fileName, D * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @return A {@link Mono} containing a {@link DataLakeDirectoryAsyncClient} used to interact with the directory * created. */ @@ -1243,8 +1243,8 @@ public Mono createDirectory(String directoryName) * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param overwrite Whether to overwrite, should a directory exist. * @return A {@link Mono} containing a {@link DataLakeDirectoryAsyncClient} used to interact with the directory * created. @@ -1282,8 +1282,8 @@ public Mono createDirectory(String directoryName, * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param permissions POSIX access permissions for the directory owner, the directory owning group, and others. * @param umask Restricts permissions of the directory to be created. * @param headers {@link PathHttpHeaders} @@ -1347,8 +1347,8 @@ public Mono> createDirectoryWithResponse( * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param options {@link DataLakePathCreateOptions} * @return A {@link Mono} containing a {@link Response} whose {@link Response#getValue() value} contains a {@link * DataLakeDirectoryAsyncClient} used to interact with the directory created. @@ -1378,8 +1378,8 @@ public Mono> createDirectoryWithResponse( * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @return A {@link Mono} containing a {@link DataLakeDirectoryAsyncClient} used to interact with the directory * created. */ @@ -1418,8 +1418,8 @@ public Mono createDirectoryIfNotExists(String dire * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param options {@link DataLakePathCreateOptions} * @return A {@link Mono} containing a {@link Response} whose {@link Response#getValue() value} contains a * {@link DataLakeDirectoryAsyncClient} used to interact with the directory created. If {@link Response}'s status @@ -1459,8 +1459,8 @@ public Mono> createDirectoryIfNotExistsWi * * * - * @param directoryName Name of the directory to delete. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to delete. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @return A reactive response signalling completion. */ @ServiceMethod(returns = ReturnType.SINGLE) @@ -1486,8 +1486,8 @@ public Mono deleteDirectory(String directoryName) { * * * - * @param directoryName Name of the directory to delete. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to delete. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param recursive Whether to delete all paths beneath the directory. * @param requestConditions {@link DataLakeRequestConditions} * @return A {@link Mono} containing status code and HTTP headers @@ -1524,8 +1524,8 @@ public Mono> deleteDirectoryWithResponse(String directoryName, bo * * * - * @param directoryName Name of the directory to delete. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to delete. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @return a reactive response signaling completion. {@code true} indicates that the specified directory was * successfully deleted, {@code false} indicates that the specified directory did not exist. */ @@ -1560,8 +1560,8 @@ public Mono deleteDirectoryIfExists(String directoryName) { * * * - * @param directoryName Name of the directory to delete. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to delete. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param options {@link DataLakePathDeleteOptions} * @return A reactive response signaling completion. If {@link Response}'s status code is 200, the file was * successfully deleted. If status code is 404, the file does not exist. diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemClient.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemClient.java index 19fea2e7b5e8..8f1eab9b725f 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemClient.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemClient.java @@ -142,8 +142,8 @@ public class DataLakeFileSystemClient { * Initializes a new DataLakeFileClient object by concatenating fileName to the end of DataLakeFileSystemClient's * URL. The new DataLakeFileClient uses the same request policy pipeline as the DataLakeFileSystemClient. * - * @param fileName A {@code String} representing the name of the file. If the path name contains special characters, - * pass in the url encoded version of the path name. + * @param fileName A {@code String} representing the name of the file. Pass the name unencoded; the client + * percent-encodes it when building the request URL. * *

Code Samples

* @@ -170,8 +170,8 @@ public DataLakeFileClient getFileClient(String fileName) { * DataLakeFileSystemClient's URL. The new DataLakeDirectoryClient uses the same request policy pipeline as the * DataLakeFileSystemClient. * - * @param directoryName A {@code String} representing the name of the directory. If the path name contains special - * characters, pass in the url encoded version of the path name. + * @param directoryName A {@code String} representing the name of the directory. Pass the name unencoded; the client + * percent-encodes it when building the request URL. * *

Code Samples

* @@ -1151,8 +1151,8 @@ public Response deleteFileIfExistsWithResponse(String fileName, DataLak * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @return A {@link DataLakeDirectoryClient} used to interact with the directory created. */ @ServiceMethod(returns = ReturnType.SINGLE) @@ -1173,8 +1173,8 @@ public DataLakeDirectoryClient createDirectory(String directoryName) { * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param overwrite Whether to overwrite, should a directory exist. * @return A {@link DataLakeDirectoryClient} used to interact with the directory created. */ @@ -1210,8 +1210,8 @@ public DataLakeDirectoryClient createDirectory(String directoryName, boolean ove * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param permissions POSIX access permissions for the directory owner, the directory owning group, and others. * @param umask Restricts permissions of the directory to be created. * @param headers {@link PathHttpHeaders} @@ -1274,8 +1274,8 @@ public Response createDirectoryWithResponse(String dire * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param options {@link DataLakePathCreateOptions} * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised. * @param context Additional context that is passed through the Http pipeline during the service call. @@ -1305,8 +1305,8 @@ public Response createDirectoryWithResponse(String dire * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @return A {@link DataLakeDirectoryClient} used to interact with the subdirectory created. */ @ServiceMethod(returns = ReturnType.SINGLE) @@ -1344,8 +1344,8 @@ public DataLakeDirectoryClient createDirectoryIfNotExists(String directoryName) * * * - * @param directoryName Name of the directory to create. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to create. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param options {@link DataLakePathCreateOptions} * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised. * @param context Additional context that is passed through the Http pipeline during the service call. @@ -1376,8 +1376,8 @@ public Response createDirectoryIfNotExistsWithResponse( * * * - * @param directoryName Name of the directory to delete. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to delete. Pass the name unencoded; the client percent-encodes it when + * building the request URL. */ @ServiceMethod(returns = ReturnType.SINGLE) public void deleteDirectory(String directoryName) { @@ -1403,8 +1403,8 @@ public void deleteDirectory(String directoryName) { * * * - * @param directoryName Name of the directory to delete. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to delete. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param recursive Whether to delete all paths beneath the directory. * @param requestConditions {@link DataLakeRequestConditions} * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised. @@ -1431,8 +1431,8 @@ public Response deleteDirectoryWithResponse(String directoryName, boolean * * * - * @param directoryName Name of the directory to delete. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to delete. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @return {@code true} if the directory is successfully deleted, {@code false} if the directory does not exist. */ @ServiceMethod(returns = ReturnType.SINGLE) @@ -1466,8 +1466,8 @@ public boolean deleteDirectoryIfExists(String directoryName) { * * * - * @param directoryName Name of the directory to delete. If the path name contains special characters, pass in the - * url encoded version of the path name. + * @param directoryName Name of the directory to delete. Pass the name unencoded; the client percent-encodes it when + * building the request URL. * @param options {@link DataLakePathDeleteOptions} * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised. * @param context Additional context that is passed through the Http pipeline during the service call. diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakePathClientBuilder.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakePathClientBuilder.java index 667da59d3de9..eed89e2a7412 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakePathClientBuilder.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakePathClientBuilder.java @@ -426,8 +426,8 @@ public DataLakePathClientBuilder fileSystemName(String fileSystemName) { /** * Sets the name of the file/directory. * - * @param pathName Name of the path. If the path name contains special characters, pass in the url encoded version - * of the path name. + * @param pathName Name of the path. Pass the name unencoded; the client percent-encodes it when building the + * request URL. * @return the updated DataLakePathClientBuilder object * @throws NullPointerException If {@code pathName} is {@code null} */ diff --git a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakePathNameEncodingTests.java b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakePathNameEncodingTests.java new file mode 100644 index 000000000000..752403593d26 --- /dev/null +++ b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakePathNameEncodingTests.java @@ -0,0 +1,406 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.storage.file.datalake; + +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineBuilder; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.test.utils.MockTokenCredential; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Offline tests that document exactly how {@code azure-storage-file-datalake} treats special characters in path names + * for {@code getFileClient}, {@code getDirectoryClient} and {@code renameWithResponse}. + * + *

These tests do not talk to the service. They capture the {@link HttpRequest} that the SDK would have put on the + * wire so the encoding contract can be asserted deterministically.

+ * + *

The contract being verified is:

+ *
    + *
  • Path names passed to the client factory methods are stored verbatim - the SDK never URL-decodes them + * (see {@code DataLakePathAsyncClient}'s constructor which assigns {@code this.pathName = pathName}).
  • + *
  • The SDK percent-encodes the path itself when building the request URL. This is done by + * {@code UrlEscapers.PATH_ESCAPER} in {@code azure-core} because the generated {@code PathsService} declares + * {@code @PathParam("path")} without {@code encoded = true}.
  • + *
  • The rename source header is encoded by {@code Utility.urlEncode} in + * {@code DataLakePathAsyncClient.renameWithResponse}.
  • + *
+ */ +public class DataLakePathNameEncodingTests { + private static final String ENDPOINT = "https://account.dfs.core.windows.net"; + private static final String FILE_SYSTEM_NAME = "filesystem"; + + /** + * Characters that {@code UrlEscapers.PATH_ESCAPER} treats as safe, meaning they travel to the service as-is and + * must therefore NOT be percent-encoded by the caller. + */ + private static final String UNRESERVED_AND_SUB_DELIMS = "-._~!$&'()*+,;=:@"; + + private static class RequestCapturingHttpClient implements HttpClient { + private final HttpHeaders responseHeaders; + private final Integer forcedStatus; + private volatile HttpRequest request; + + RequestCapturingHttpClient() { + this(new HttpHeaders(), null); + } + + RequestCapturingHttpClient(HttpHeaders responseHeaders, Integer forcedStatus) { + this.responseHeaders = responseHeaders; + this.forcedStatus = forcedStatus; + } + + @Override + public Mono send(HttpRequest request) { + this.request = request; + int status = forcedStatus != null ? forcedStatus : (request.getHttpMethod() == HttpMethod.PUT ? 201 : 200); + return Mono.just(new MockHttpResponse(request, status, responseHeaders)); + } + + HttpRequest getRequest() { + return request; + } + + String getUrlPath() { + return request.getUrl().getPath(); + } + } + + private static DataLakeFileSystemClient fileSystemClient(HttpClient httpClient) { + HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(httpClient).build(); + + return new DataLakeFileSystemClientBuilder().endpoint(ENDPOINT) + .fileSystemName(FILE_SYSTEM_NAME) + .credential(new MockTokenCredential()) + .pipeline(pipeline) + .buildClient(); + } + + /* + * ------------------------------------------------------------------------------------------------------------ + * 1. The name handed to getFileClient/getDirectoryClient is stored verbatim - it is neither decoded nor encoded. + * ------------------------------------------------------------------------------------------------------------ + */ + + @ParameterizedTest + @ValueSource( + strings = { + "file", + "100%done", + "a#b", + "a+b", + "a b", + "a&b", + "a?b", + "a@b", + "a=b", + "a,b", + "a;b", + "a'b", + "a\"b", + "ac", + "a|b", + "a*b", + "a:b", + "a\\b", + "a[b]c", + "a{b}c", + "a^b", + "a`b", + "斑點", + "%E6%96%91%E9%BB%9E", + "path/to]a file" }) + public void pathNameIsStoredVerbatim(String name) { + DataLakeFileSystemClient fileSystemClient = fileSystemClient(new RequestCapturingHttpClient()); + + assertEquals(name, fileSystemClient.getFileClient(name).getFilePath()); + assertEquals(name, fileSystemClient.getDirectoryClient(name).getDirectoryPath()); + } + + /** + * Raw {@code %} and {@code #} used to throw {@code IllegalArgumentException} ("Illegal hex characters in escape + * (%) pattern") because the SDK called {@code Utility.urlDecode} on the name. Since 12.19.0 the name is stored + * verbatim, so raw names never throw. + */ + @ParameterizedTest + @ValueSource(strings = { "100%done", "50%", "%", "%zz", "a%2", "report#1", "a%b#c" }) + public void rawPercentOrHashNeverThrows(String name) { + DataLakeFileSystemClient fileSystemClient = fileSystemClient(new RequestCapturingHttpClient()); + + assertDoesNotThrow(() -> fileSystemClient.getFileClient(name)); + assertDoesNotThrow(() -> fileSystemClient.getDirectoryClient(name)); + assertDoesNotThrow(() -> fileSystemClient.getDirectoryClient("dir").getFileClient(name)); + assertDoesNotThrow(() -> fileSystemClient.getDirectoryClient("dir").getSubdirectoryClient(name)); + } + + /* + * ------------------------------------------------------------------------------------------------------------ + * 2. The SDK percent-encodes the path when it builds the request URL. + * ------------------------------------------------------------------------------------------------------------ + */ + + @ParameterizedTest + @MethodSource("wireEncodingSupplier") + public void specialCharactersAreEncodedByTheSdkOnTheWire(String rawName, String expectedEncodedPath) { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + + fileSystemClient(httpClient).getFileClient(rawName).create(true); + + assertEquals("/" + FILE_SYSTEM_NAME + "/" + expectedEncodedPath, httpClient.getUrlPath()); + } + + @ParameterizedTest + @MethodSource("wireEncodingSupplier") + public void specialCharactersAreEncodedByTheSdkOnTheWireForDirectories(String rawName, String expectedEncodedPath) { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + + fileSystemClient(httpClient).getDirectoryClient(rawName).create(true); + + assertEquals("/" + FILE_SYSTEM_NAME + "/" + expectedEncodedPath, httpClient.getUrlPath()); + } + + private static Stream wireEncodingSupplier() { + return Stream.of( + // raw name passed to the SDK | path that appears on the wire + Arguments.of("file.txt", "file.txt"), + // Characters that are reserved/unsafe in a URL path are percent-encoded by the SDK. + Arguments.of("a b", "a%20b"), Arguments.of("a\"b", "a%22b"), Arguments.of("report#1", "report%231"), + Arguments.of("100%done", "100%25done"), Arguments.of("ab", "a%3Eb"), + Arguments.of("a?b", "a%3Fb"), Arguments.of("a[b", "a%5Bb"), Arguments.of("a\\b", "a%5Cb"), + Arguments.of("a]b", "a%5Db"), Arguments.of("a^b", "a%5Eb"), Arguments.of("a`b", "a%60b"), + Arguments.of("a{b", "a%7Bb"), Arguments.of("a|b", "a%7Cb"), Arguments.of("a}b", "a%7Db"), + // '/' is a path separator in the name; it is encoded so the whole name is addressed as one path parameter. + Arguments.of("dir/file.txt", "dir%2Ffile.txt"), + // Non-ASCII is UTF-8 percent-encoded. + Arguments.of("斑點", "%E6%96%91%E9%BB%9E"), + // Unreserved + sub-delims are safe and are NOT encoded. + Arguments.of("a+b", "a+b"), Arguments.of("a&b", "a&b"), Arguments.of("a=b", "a=b"), + Arguments.of("a,b", "a,b"), Arguments.of("a;b", "a;b"), Arguments.of("a'b", "a'b"), + Arguments.of("a!b", "a!b"), Arguments.of("a$b", "a$b"), Arguments.of("a(b)c", "a(b)c"), + Arguments.of("a*b", "a*b"), Arguments.of("a:b", "a:b"), Arguments.of("a@b", "a@b"), + Arguments.of("a-b_c.d~e", "a-b_c.d~e")); + } + + /** + * Every unreserved character and sub-delimiter is placed on the wire as-is. Callers must not encode these. + */ + @Test + public void unreservedAndSubDelimitersAreNotEncoded() { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + String name = "file" + UNRESERVED_AND_SUB_DELIMS + "txt"; + + fileSystemClient(httpClient).getFileClient(name).create(true); + + assertEquals("/" + FILE_SYSTEM_NAME + "/" + name, httpClient.getUrlPath()); + } + + /** + * The characters the issue asks about - {@code " \ / : | < > * ?} - are all accepted by the client. The client + * performs no name validation at all; only the service enforces naming rules. + */ + @ParameterizedTest + @MethodSource("windowsReservedCharacterSupplier") + public void windowsReservedCharactersAreNotRejectedByTheClient(String rawName, String expectedEncodedPath) { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + + assertDoesNotThrow(() -> fileSystemClient(httpClient).getFileClient(rawName).create(true)); + + assertEquals("/" + FILE_SYSTEM_NAME + "/" + expectedEncodedPath, httpClient.getUrlPath()); + } + + private static Stream windowsReservedCharacterSupplier() { + return Stream.of(Arguments.of("a\"b", "a%22b"), Arguments.of("a\\b", "a%5Cb"), Arguments.of("a/b", "a%2Fb"), + Arguments.of("a:b", "a:b"), Arguments.of("a|b", "a%7Cb"), Arguments.of("ab", "a%3Eb"), Arguments.of("a*b", "a*b"), Arguments.of("a?b", "a%3Fb")); + } + + /* + * ------------------------------------------------------------------------------------------------------------ + * 3. Pre-encoding by the caller results in double encoding - i.e. a different resource. + * ------------------------------------------------------------------------------------------------------------ + */ + + @ParameterizedTest + @CsvSource({ + "my%20file,my%2520file", + "my%2Ffile,my%252Ffile", + "test%25test,test%2525test", + "%E6%96%91%E9%BB%9E,%25E6%2596%2591%25E9%25BB%259E" }) + public void callerSuppliedEncodingIsDoubleEncoded(String preEncodedName, String expectedEncodedPath) { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + + fileSystemClient(httpClient).getFileClient(preEncodedName).create(true); + + // The '%' of the caller's escape sequence is itself escaped to %25, so a differently named resource is + // addressed. Callers must pass the raw, unencoded name. + assertEquals("/" + FILE_SYSTEM_NAME + "/" + expectedEncodedPath, httpClient.getUrlPath()); + } + + /* + * ------------------------------------------------------------------------------------------------------------ + * 4. Sub-paths: DataLakeDirectoryClient.getFileClient / getSubdirectoryClient concatenate with '/' and then the + * whole path is encoded as a single path parameter. + * ------------------------------------------------------------------------------------------------------------ + */ + + @Test + public void directoryChildPathsAreConcatenatedRawAndEncodedOnce() { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + DataLakeDirectoryClient directoryClient = fileSystemClient(httpClient).getDirectoryClient("my dir"); + + DataLakeFileClient fileClient = directoryClient.getFileClient("100%done.txt"); + assertEquals("my dir/100%done.txt", fileClient.getFilePath()); + + fileClient.create(true); + assertEquals("/" + FILE_SYSTEM_NAME + "/my%20dir%2F100%25done.txt", httpClient.getUrlPath()); + + DataLakeDirectoryClient subdirectoryClient = directoryClient.getSubdirectoryClient("sub+dir"); + assertEquals("my dir/sub+dir", subdirectoryClient.getDirectoryPath()); + + subdirectoryClient.create(true); + assertEquals("/" + FILE_SYSTEM_NAME + "/my%20dir%2Fsub+dir", httpClient.getUrlPath()); + } + + /* + * ------------------------------------------------------------------------------------------------------------ + * 5. renameWithResponse: the destination is a raw path too, and the SDK builds the x-ms-rename-source header. + * ------------------------------------------------------------------------------------------------------------ + */ + + @ParameterizedTest + @MethodSource("renameSupplier") + public void renameEncodesSourceHeaderAndDestinationUrl(String sourceName, String destinationName, + String expectedRenameSource, String expectedDestinationPath) { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + DataLakeFileClient fileClient = fileSystemClient(httpClient).getFileClient(sourceName); + + DataLakeFileClient renamed + = fileClient.renameWithResponse(null, destinationName, null, null, null, null).getValue(); + + HttpRequest request = httpClient.getRequest(); + assertNotNull(request); + + // Source is sent in the x-ms-rename-source header, percent-encoded by the SDK. + assertEquals("/" + FILE_SYSTEM_NAME + "/" + expectedRenameSource, + request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-rename-source"))); + + // Destination is the request URL, percent-encoded by the SDK. + assertEquals("/" + FILE_SYSTEM_NAME + "/" + expectedDestinationPath, request.getUrl().getPath()); + + // The returned client reports the raw destination name, exactly as it was passed in. + assertEquals(destinationName, renamed.getFilePath()); + } + + private static Stream renameSupplier() { + return Stream.of( + // source | destination | expected x-ms-rename-source | expected destination path + Arguments.of("file.txt", "renamed.txt", "file.txt", "renamed.txt"), + Arguments.of("100%done.txt", "100%25done.txt", "100%25done.txt", "100%2525done.txt"), + Arguments.of("a b.txt", "c d.txt", "a%20b.txt", "c%20d.txt"), + Arguments.of("a+b.txt", "c+d.txt", "a%2Bb.txt", "c+d.txt"), + Arguments.of("dir/file.txt", "dir/renamed.txt", "dir%2Ffile.txt", "dir%2Frenamed.txt"), + Arguments.of("斑點.txt", "點斑.txt", "%E6%96%91%E9%BB%9E.txt", "%E9%BB%9E%E6%96%91.txt")); + } + + /** + * Regression test. The client returned by {@code rename}/{@code renameWithResponse} used to be constructed with + * {@code Utility.urlEncode(pathName)}, which left it internally inconsistent: blob-backed operations (such as + * {@code getProperties}) addressed the correct path while Data Lake endpoint operations (such as {@code create}) + * double-encoded it. The returned client must address exactly the path that the rename created. + */ + @Test + public void clientReturnedByRenameAddressesTheRenamedPath() { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + DataLakeFileSystemClient fileSystemClient = fileSystemClient(httpClient); + + DataLakeFileClient renamed = fileSystemClient.getFileClient("source.txt").rename(null, "my file.txt"); + String renameUrl = httpClient.getUrlPath(); + + assertEquals("/" + FILE_SYSTEM_NAME + "/my%20file.txt", renameUrl); + assertEquals("my file.txt", renamed.getFilePath()); + assertEquals(ENDPOINT + "/" + FILE_SYSTEM_NAME + "/my%20file.txt", renamed.getFileUrl()); + + // A Data Lake endpoint operation addresses the path that was just created. + renamed.createIfNotExists(); + assertEquals(renameUrl, httpClient.getUrlPath()); + + // A blob endpoint operation addresses the same path. + renamed.getProperties(); + assertEquals(renameUrl, httpClient.getUrlPath()); + + // And it agrees with a freshly acquired client. + fileSystemClient.getFileClient("my file.txt").createIfNotExists(); + assertEquals(renameUrl, httpClient.getUrlPath()); + } + + /** + * The same consistency requirement for directories, and for a name containing a literal {@code %}. + */ + @Test + public void directoryClientReturnedByRenameAddressesTheRenamedPath() { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + DataLakeFileSystemClient fileSystemClient = fileSystemClient(httpClient); + + DataLakeDirectoryClient renamed = fileSystemClient.getDirectoryClient("source").rename(null, "100%done dir"); + String renameUrl = httpClient.getUrlPath(); + + assertEquals("/" + FILE_SYSTEM_NAME + "/100%25done%20dir", renameUrl); + assertEquals("100%done dir", renamed.getDirectoryPath()); + + renamed.createIfNotExists(); + assertEquals(renameUrl, httpClient.getUrlPath()); + + renamed.getProperties(); + assertEquals(renameUrl, httpClient.getUrlPath()); + } + + /** + * {@code undeletePath} builds its result client through the same copy constructors, so it must round-trip the + * deleted path name unchanged too. + */ + @Test + public void undeletedClientReportsRawPathName() { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient( + new HttpHeaders().set(HttpHeaderName.fromString("x-ms-resource-type"), "file"), 200); + + DataLakePathClient undeleted = fileSystemClient(httpClient).undeletePath("my file.txt", "deletionId"); + + assertEquals("my file.txt", undeleted.getObjectPath()); + assertEquals(ENDPOINT + "/" + FILE_SYSTEM_NAME + "/my%20file.txt", undeleted.getPathUrl()); + } + + /** + * The rename source header and the request URL use two different (but both valid) percent-encoding sets. Anything + * a caller pre-encodes is escaped again in both, so pre-encoding is never correct. + */ + @Test + public void renameOfPreEncodedNameIsDoubleEncoded() { + RequestCapturingHttpClient httpClient = new RequestCapturingHttpClient(); + + fileSystemClient(httpClient).getFileClient("my%20file.txt") + .renameWithResponse(null, "my%20renamed.txt", null, null, null, null); + + HttpRequest request = httpClient.getRequest(); + assertEquals("/" + FILE_SYSTEM_NAME + "/my%2520file.txt", + request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-rename-source"))); + assertEquals("/" + FILE_SYSTEM_NAME + "/my%2520renamed.txt", request.getUrl().getPath()); + } +} From cb3d780770b520a9eb72a30816681269bbc8cd35 Mon Sep 17 00:00:00 2001 From: Isabelle <141270045+ibrandes@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:08:08 -0700 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../storage/file/datalake/DataLakeFileSystemAsyncClient.java | 4 ++-- .../azure/storage/file/datalake/DataLakeFileSystemClient.java | 4 ++-- .../storage/file/datalake/DataLakePathClientBuilder.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemAsyncClient.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemAsyncClient.java index 56973be0ac8a..ab7ba75549d5 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemAsyncClient.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemAsyncClient.java @@ -197,8 +197,8 @@ public DataLakeFileAsyncClient getFileAsyncClient(String fileName) { * * * - * @param directoryName A {@code String} representing the name of the directory. Pass the name unencoded; the client - * percent-encodes it when building the request URL. + * @param directoryName A {@code String} representing the name of the directory. + * Pass the name unencoded; the client percent-encodes it when building the request URL. * @return A new {@link DataLakeDirectoryAsyncClient} object which references the directory with the specified name * in this file system. */ diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemClient.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemClient.java index 8f1eab9b725f..9e97dc2dffd3 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemClient.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeFileSystemClient.java @@ -170,8 +170,8 @@ public DataLakeFileClient getFileClient(String fileName) { * DataLakeFileSystemClient's URL. The new DataLakeDirectoryClient uses the same request policy pipeline as the * DataLakeFileSystemClient. * - * @param directoryName A {@code String} representing the name of the directory. Pass the name unencoded; the client - * percent-encodes it when building the request URL. + * @param directoryName A {@code String} representing the name of the directory. + * Pass the name unencoded; the client percent-encodes it when building the request URL. * *

Code Samples

* diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakePathClientBuilder.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakePathClientBuilder.java index eed89e2a7412..195543aa5e9c 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakePathClientBuilder.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakePathClientBuilder.java @@ -426,8 +426,8 @@ public DataLakePathClientBuilder fileSystemName(String fileSystemName) { /** * Sets the name of the file/directory. * - * @param pathName Name of the path. Pass the name unencoded; the client percent-encodes it when building the - * request URL. + * @param pathName Name of the path. + * Pass the name unencoded; the client percent-encodes it when building the request URL. * @return the updated DataLakePathClientBuilder object * @throws NullPointerException If {@code pathName} is {@code null} */