diff --git a/sdk/storage/azure-storage-blob/CHANGELOG.md b/sdk/storage/azure-storage-blob/CHANGELOG.md index 379ea2aa0d61..92d9fd7952f0 100644 --- a/sdk/storage/azure-storage-blob/CHANGELOG.md +++ b/sdk/storage/azure-storage-blob/CHANGELOG.md @@ -9,6 +9,7 @@ - Fixed an issue where `destination_snapshot` on a blob's copy properties was always `None` when listing blobs with `response_format="arrow"`. - Fixed an issue with the new generation where listing page ranges for an empty page blob could raise a `ValueError` instead of returning an empty list. +- Fixed an issue where a SAS generated for a blob name containing a backslash (`\`) was invalid because the backslash was not normalized to a forward slash when building the signed resource. ### Other Changes - Added public `SignedIdentifier` model and updated `ContainerClient.get_container_access_policy` diff --git a/sdk/storage/azure-storage-blob/azure/storage/blob/_shared_access_signature.py b/sdk/storage/azure-storage-blob/azure/storage/blob/_shared_access_signature.py index 46cb94f9c886..a596b76b1840 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/_shared_access_signature.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/_shared_access_signature.py @@ -160,6 +160,7 @@ def generate_blob( :return: A Shared Access Signature (sas) token. :rtype: str """ + blob_name = blob_name.replace("\\", "/") resource_path = container_name + "/" + blob_name sas = _BlobSharedAccessHelper() diff --git a/sdk/storage/azure-storage-blob/tests/test_common_blob.py b/sdk/storage/azure-storage-blob/tests/test_common_blob.py index 701aa434b842..7bf7aacef7c7 100644 --- a/sdk/storage/azure-storage-blob/tests/test_common_blob.py +++ b/sdk/storage/azure-storage-blob/tests/test_common_blob.py @@ -1995,6 +1995,34 @@ def test_sas_access_blob(self, **kwargs): # Assert assert self.byte_data == content + @pytest.mark.live_test_only + @BlobPreparer() + def test_sas_access_blob_name_with_backslash(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + blob_name = "dir\\file" + blob = self.bsc.get_blob_client(self.container_name, blob_name) + blob.upload_blob(self.byte_data, length=len(self.byte_data), overwrite=True) + + token = self.generate_sas( + generate_blob_sas, + blob.account_name, + blob.container_name, + blob.blob_name, + account_key=blob.credential.account_key, + permission=BlobSasPermissions(read=True), + expiry=datetime.utcnow() + timedelta(hours=1), + ) + + # Act + service = BlobClient.from_blob_url(blob.url, credential=token) + content = service.download_blob().readall() + + # Assert + assert self.byte_data == content + @pytest.mark.live_test_only @BlobPreparer() def test_sas_access_blob_snapshot(self, **kwargs): diff --git a/sdk/storage/azure-storage-file-datalake/CHANGELOG.md b/sdk/storage/azure-storage-file-datalake/CHANGELOG.md index c9f1a8d6d08c..8c584a0f0f3b 100644 --- a/sdk/storage/azure-storage-file-datalake/CHANGELOG.md +++ b/sdk/storage/azure-storage-file-datalake/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features Added +### Bugs Fixed +- Fixed an issue where a SAS generated for a path containing a backslash (`\`) was invalid because the backslash was not normalized to a forward slash when building the signed resource. + ## 12.26.0b1 (2026-08-10) ### Features Added diff --git a/sdk/storage/azure-storage-file-datalake/azure/storage/filedatalake/_shared_access_signature.py b/sdk/storage/azure-storage-file-datalake/azure/storage/filedatalake/_shared_access_signature.py index 55d3451c9d76..a38025e41cea 100644 --- a/sdk/storage/azure-storage-file-datalake/azure/storage/filedatalake/_shared_access_signature.py +++ b/sdk/storage/azure-storage-file-datalake/azure/storage/filedatalake/_shared_access_signature.py @@ -339,7 +339,12 @@ def generate_directory_sas( :return: A Shared Access Signature (sas) token. :rtype: str """ - depth = len(directory_name.strip("/").split("/")) + canonicalized_directory_name = directory_name.replace("\\", "/") + depth = ( + 0 + if canonicalized_directory_name in ["", "/"] + else len(canonicalized_directory_name.strip("/").split("/")) + ) return generate_blob_sas( account_name=account_name, container_name=file_system_name, diff --git a/sdk/storage/azure-storage-file-datalake/tests/test_directory.py b/sdk/storage/azure-storage-file-datalake/tests/test_directory.py index ef6fb8e182d2..3cf7c0174d6f 100644 --- a/sdk/storage/azure-storage-file-datalake/tests/test_directory.py +++ b/sdk/storage/azure-storage-file-datalake/tests/test_directory.py @@ -1576,6 +1576,38 @@ def test_using_directory_sas_to_create_file(self, **kwargs): with pytest.raises(HttpResponseError): directory_client.delete_directory() + @pytest.mark.live_test_only + @DataLakePreparer() + def test_using_directory_sas_with_backslash(self, **kwargs): + datalake_storage_account_name = kwargs.pop("datalake_storage_account_name") + datalake_storage_account_key = kwargs.pop("datalake_storage_account_key") + + self._setUp(datalake_storage_account_name, datalake_storage_account_key) + # SAS URL is calculated from storage key, so this test runs live only + + directory_name = self._get_directory_reference() + "\\sub" + client = self.dsc.get_directory_client(self.file_system_name, directory_name) + client.create_directory() + + token = generate_directory_sas( + self.dsc.account_name, + self.file_system_name, + directory_name, + self.dsc.credential.account_key, + permission=DirectorySasPermissions(read=True), + expiry=datetime.utcnow() + timedelta(hours=1), + ) + + # sdd must match the depth of the canonicalized resource, not the raw name + assert "sdd=2" in token + + directory_client = DataLakeDirectoryClient( + self.dsc.url, self.file_system_name, directory_name, credential=token + ) + access_control = directory_client.get_access_control() + + assert access_control is not None + @DataLakePreparer() @recorded_by_proxy def test_storage_account_audience_dir_client(self, **kwargs): diff --git a/sdk/storage/azure-storage-file-share/CHANGELOG.md b/sdk/storage/azure-storage-file-share/CHANGELOG.md index 20609dda96ef..10117721afa8 100644 --- a/sdk/storage/azure-storage-file-share/CHANGELOG.md +++ b/sdk/storage/azure-storage-file-share/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features Added +### Bugs Fixed +- Fixed an issue where a SAS generated for a file path containing a backslash (`\`) was invalid because the backslash was not normalized to a forward slash when building the signed resource. + ## 12.27.0b1 (2026-08-10) ### Features Added diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_shared_access_signature.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_shared_access_signature.py index 0a7f24fa711b..c82ca3135082 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_shared_access_signature.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_shared_access_signature.py @@ -141,6 +141,7 @@ def generate_file( resource_path += "/" + str(directory_name) if file_name is not None: resource_path += "/" + str(file_name) + resource_path = resource_path.replace("\\", "/") sas = _FileSharedAccessHelper() sas.add_base(permission, expiry, start, ip, protocol, self.x_ms_version) diff --git a/sdk/storage/azure-storage-file-share/tests/test_file.py b/sdk/storage/azure-storage-file-share/tests/test_file.py index 1c5a3695e2bb..2afe2a35fd8f 100644 --- a/sdk/storage/azure-storage-file-share/tests/test_file.py +++ b/sdk/storage/azure-storage-file-share/tests/test_file.py @@ -3511,6 +3511,42 @@ def test_sas_access_file(self, **kwargs): # Assert assert self.short_byte_data == content + @pytest.mark.live_test_only + @FileSharePreparer() + def test_sas_access_file_name_with_backslash(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + share_client = self.fsc.get_share_client(self.share_name) + try: + share_client.create_directory("dir") + except ResourceExistsError: + pass + file_client = self._create_file("dir\\file") + + token = self.generate_sas( + generate_file_sas, + file_client.account_name, + file_client.share_name, + file_client.file_path, + file_client.credential.account_key, + permission=FileSasPermissions(read=True), + expiry=datetime.utcnow() + timedelta(hours=1), + ) + + # Act + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path="dir\\file", + credential=token, + ) + content = file_client.download_file().readall() + + # Assert + assert self.short_byte_data == content + @FileSharePreparer() @recorded_by_proxy def test_sas_signed_identifier(self, **kwargs):