diff --git a/src/main/java/org/gaul/s3proxy/azureblob/AzureBlobStore.java b/src/main/java/org/gaul/s3proxy/azureblob/AzureBlobStore.java index 21e94341..8de0bbbe 100644 --- a/src/main/java/org/gaul/s3proxy/azureblob/AzureBlobStore.java +++ b/src/main/java/org/gaul/s3proxy/azureblob/AzureBlobStore.java @@ -120,6 +120,8 @@ public final class AzureBlobStore extends BaseBlobStore { private static final String STUB_BLOB_PREFIX = ".s3proxy/stubs/"; private static final String TARGET_BLOB_NAME_TAG = "s3proxy_target_blob_name"; + private static final String BASE64URL_PREFIX = "base64url:"; + private static final String TARGET_BLOB_NAME_MARKER = "s3proxy-v1:"; private static final HashFunction MD5 = Hashing.md5(); // Disable retries since client should retry on errors. private static final RequestRetryOptions NO_RETRY_OPTIONS = new RequestRetryOptions( @@ -644,7 +646,8 @@ public MultipartUpload initiateMultipartUpload(String container, stubBlobClient.uploadWithResponse(uploadOptions, null, null); var tags = new java.util.HashMap(); - tags.put(TARGET_BLOB_NAME_TAG, targetBlobName); + tags.put(TARGET_BLOB_NAME_TAG, + encodeTargetBlobNameTagValue(targetBlobName)); stubBlobClient.setTags(tags); return MultipartUpload.create(container, targetBlobName, @@ -713,7 +716,8 @@ public String completeMultipartUpload(MultipartUpload mpu, throw bse; } - String targetBlobName = stubTags.get(TARGET_BLOB_NAME_TAG); + String targetBlobName = decodeTargetBlobNameTagValue( + stubTags.get(TARGET_BLOB_NAME_TAG)); if (targetBlobName == null) { throw new IllegalArgumentException( "Stub blob missing target name tag: uploadId=" + uploadKey); @@ -977,7 +981,8 @@ public List listMultipartUpload(MultipartUpload mpu) { String targetBlobName; try { var stubTags = stubBlobClient.getTags(); - targetBlobName = stubTags.get(TARGET_BLOB_NAME_TAG); + targetBlobName = decodeTargetBlobNameTagValue( + stubTags.get(TARGET_BLOB_NAME_TAG)); } catch (BlobStorageException bse) { if (bse.getErrorCode().equals(BlobErrorCode.BLOB_NOT_FOUND)) { throw new IllegalArgumentException( @@ -1055,7 +1060,8 @@ public List listMultipartUploads(String container) { continue; } - String targetBlobName = tags.get(TARGET_BLOB_NAME_TAG); + String targetBlobName = decodeTargetBlobNameTagValue( + tags.get(TARGET_BLOB_NAME_TAG)); builder.add(MultipartUpload.create(container, targetBlobName, uploadKey, null, null)); } @@ -1151,6 +1157,33 @@ private static String makeBlockId(String nonce, int partNumber) { rawId.getBytes(StandardCharsets.UTF_8)); } + static String encodeTargetBlobNameTagValue(String blobName) { + String raw = TARGET_BLOB_NAME_MARKER + blobName; + return BASE64URL_PREFIX + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + static String decodeTargetBlobNameTagValue(@Nullable String tagValue) { + if (tagValue == null || !tagValue.startsWith(BASE64URL_PREFIX)) { + return tagValue; + } + String decodedValue; + try { + decodedValue = new String(Base64.getUrlDecoder().decode( + tagValue.substring(BASE64URL_PREFIX.length())), + StandardCharsets.UTF_8); + } catch (IllegalArgumentException iae) { + // Allow legacy/plain values that happen to start with the prefix. + return tagValue; + } + if (!decodedValue.startsWith(TARGET_BLOB_NAME_MARKER)) { + // Allow legacy/plain values that decode to arbitrary strings. + return tagValue; + } + return decodedValue.substring(TARGET_BLOB_NAME_MARKER.length()); + } + /** * Translate BlobStorageException to a jclouds exception. Throws if * translated otherwise returns. diff --git a/src/test/java/org/gaul/s3proxy/azureblob/AzureBlobStoreTest.java b/src/test/java/org/gaul/s3proxy/azureblob/AzureBlobStoreTest.java new file mode 100644 index 00000000..68f561a5 --- /dev/null +++ b/src/test/java/org/gaul/s3proxy/azureblob/AzureBlobStoreTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2014-2026 Andrew Gaul + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gaul.s3proxy.azureblob; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.junit.Test; + +public final class AzureBlobStoreTest { + @Test + public void testTargetBlobNameTagEncodingIsBackwardCompatible() { + String blobName = "folder/with spaces/+-_=.txt"; + String encoded = AzureBlobStore.encodeTargetBlobNameTagValue(blobName); + + assertThat(encoded).isNotEqualTo(blobName); + assertThat(AzureBlobStore.decodeTargetBlobNameTagValue(encoded)) + .isEqualTo(blobName); + + String legacyPlain = "legacy/blob/name"; + assertThat(AzureBlobStore.decodeTargetBlobNameTagValue(legacyPlain)) + .isEqualTo(legacyPlain); + + String legacyPrefix = "base64url:legacy-not-base64-*"; + assertThat(AzureBlobStore.decodeTargetBlobNameTagValue(legacyPrefix)) + .isEqualTo(legacyPrefix); + + String legacyBase64 = "base64url:" + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("legacy-markerless".getBytes(StandardCharsets.UTF_8)); + assertThat(AzureBlobStore.decodeTargetBlobNameTagValue(legacyBase64)) + .isEqualTo(legacyBase64); + } +}