diff --git a/ChangeLog.md b/ChangeLog.md index d06a5d2d5..fb32ab1f5 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -4,6 +4,10 @@ ## Upcoming Release +Blob: + +- Copy source validation now issues a HEAD request instead of downloading the entire source blob, and no longer fails the copy with 500 when the source blob declares `Content-Encoding: gzip` (related to issue #646). + ## 2026.06 Version 3.36.0 General: diff --git a/src/blob/handlers/BlobHandler.ts b/src/blob/handlers/BlobHandler.ts index 0ab7be045..d0ef5f130 100644 --- a/src/blob/handlers/BlobHandler.ts +++ b/src/blob/handlers/BlobHandler.ts @@ -723,18 +723,35 @@ export default class BlobHandler extends BaseHandler implements IBlobHandler { context.contextId ); - // In order to retrieve proper error details we make a metadata request to the copy source. If we instead issue - // a HEAD request then the error details are not returned and reporting authentication failures to the caller - // becomes a black box. - const metadataUrl = URLBuilder.parse(copySource); - metadataUrl.setQueryParameter("comp", "metadata"); - const validationResponse: AxiosResponse = await axios.get( - metadataUrl.toString(), - { + // Azurite serves a GET of ?comp=metadata as a full blob + // download (issue #646). Besides downloading the whole source only to + // discard it, axios would fail the copy outright trying to decompress a + // source that declares Content-Encoding: gzip over bytes that are not + // really gzip. Validate access with a bodiless HEAD (Get Blob + // Properties) request instead, and only on failure repeat it as a GET + // to recover the error details from the response body, so reporting + // authentication failures does not become a black box. + let validationResponse: AxiosResponse = await axios.head(copySource, { + // Instructs axios to not throw an error for non-2xx responses + validateStatus: () => true + }); + if ( + validationResponse.status !== 200 || + validationResponse.headers["x-ms-access-tier"] === "Archive" + ) { + // Error details live only in response bodies, and reading an + // archived source must fail the copy the same way downloading it + // would, so repeat the request as a GET. + const metadataUrl = URLBuilder.parse(copySource); + metadataUrl.setQueryParameter("comp", "metadata"); + validationResponse = await axios.get(metadataUrl.toString(), { // Instructs axios to not throw an error for non-2xx responses - validateStatus: () => true - } - ); + validateStatus: () => true, + // The error body is uncompressed XML; never decompress, in case a + // race turns this retry into a 200 blob download after all. + decompress: false + }); + } if (validationResponse.status === 200) { this.logger.debug( `BlobHandler:startCopyFromURL() Successfully validated access to source account ${sourceAccount}`, diff --git a/tests/blob/sas.test.ts b/tests/blob/sas.test.ts index 3385324d3..8a9591cd0 100644 --- a/tests/blob/sas.test.ts +++ b/tests/blob/sas.test.ts @@ -552,6 +552,60 @@ describe("Shared Access Signature (SAS) authentication", () => { await blob2.beginCopyFromURL(blob1.url); }); + it("Copy blob should work when the source blob declares Content-Encoding: gzip @loki", async () => { + const tmr = new Date(); + tmr.setDate(tmr.getDate() + 1); + + const storageSharedKeyCredential = (serviceClient as any).credential; + + const sas = generateAccountSASQueryParameters( + { + expiresOn: tmr, + ipRange: { start: "0.0.0.0", end: "255.255.255.255" }, + permissions: AccountSASPermissions.parse("rwdlacup"), + protocol: SASProtocol.HttpsAndHttp, + resourceTypes: AccountSASResourceTypes.parse("co").toString(), + services: AccountSASServices.parse("btqf").toString(), + version: "2016-05-31" + }, + storageSharedKeyCredential as StorageSharedKeyCredential + ).toString(); + + const sasURL = `${serviceClient.url}?${sas}`; + const serviceClientWithSAS = new BlobServiceClient( + sasURL, + newPipeline(new AnonymousCredential(), { + // Make sure socket is closed once the operation is done. + keepAliveOptions: { enable: false } + }) + ); + + const containerName = getUniqueName("con"); + const containerClient = serviceClientWithSAS.getContainerClient( + containerName + ); + await containerClient.create(); + + const blobName1 = getUniqueName("blob"); + const blobName2 = getUniqueName("blob"); + const blob1 = containerClient.getBlockBlobClient(blobName1); + const blob2 = containerClient.getBlockBlobClient(blobName2); + + // The body is not actually gzip; the copy source validation request + // must not attempt to decompress it. + await blob1.upload("hello", 5, { + blobHTTPHeaders: { blobContentEncoding: "gzip" } + }); + + // this copy should work + const operation = await blob2.beginCopyFromURL(blob1.url); + const copyResponse = await operation.pollUntilDone(); + assert.equal("success", copyResponse.copyStatus); + + const properties = await blob2.getProperties(); + assert.strictEqual(properties.contentEncoding, "gzip"); + }); + it("Copy blob shouldn't work without write permission in account SAS to override an existing blob @loki", async () => { const tmr = new Date(); tmr.setDate(tmr.getDate() + 1);