Repository: Azure/Azurite (ts)

Issue:
Empty blob access error using Java SDK
### Which service(blob, file, queue, table) does this issue concern?

blob

### Which version of the Azurite was used?

3.32.0

### Where do you get Azurite? (npm, DockerHub, NuGet, Visual Studio Code Extension)

Docker

### What's the Node.js version?

n/a

### What problem was encountered?

Downloading an empty (0 bytes) blob from Azurite gives a 416 error response, while Azure works as expected. With version 3.31.0, both Azurite and Azure work as expected without an error.

This seems to contradict #2410, which reported the opposite behavior for an earlier Azurite version. As a consequence, #2417 was implemented.

### Steps to reproduce the issue?

See https://github.com/cnaj/azurite-empty-blob-issue-demo for a reproduction of the issue.

### Have you found a mitigation/solution?

no

Pull request description:
Ignore invalid range in blob download
Fix #2458, based on #2461

I'm not sure how to provoke an invalid range header in the unit tests; @blueww can you give me a hint?

Patch:
diff --git a/ChangeLog.md b/ChangeLog.md
index 43e3c28..18bd2b7 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -11,6 +11,8 @@ General:
 Blob:
 
 - GetBlob on Archive tier blobs now fails as expected.
+- Fixed issue of download 0 size blob with range > 0 should have header "Content-Range: bytes \*/0" in returned error. (issue #2458)
+- Aligned behavior with Azure to ignore invalid range requests for blob downloads. (issue #2458)
 
 Table:
 
diff --git a/src/blob/errors/StorageErrorFactory.ts b/src/blob/errors/StorageErrorFactory.ts
index 5fd4bca..18fabe9 100644
--- a/src/blob/errors/StorageErrorFactory.ts
+++ b/src/blob/errors/StorageErrorFactory.ts
@@ -199,13 +199,18 @@ export default class StorageErrorFactory {
     );
   }
 
-  public static getInvalidPageRange2(contextID: string): StorageError {
-    return new StorageError(
+  public static getInvalidPageRange2(contextID: string, contentRange?: string): StorageError {
+    let returnValue = new StorageError(
       416,
       "InvalidRange",
       "The range specified is invalid for the current size of the resource.",
       contextID
-    );
+    ); 
+    if(contentRange)
+    {
+      returnValue.headers!["Content-Range"] = contentRange;
+    }
+    return returnValue;
   }
 
   public static getInvalidLeaseDuration(
diff --git a/src/blob/handlers/BlobHandler.ts b/src/blob/handlers/BlobHandler.ts
index 9d5f9f4..0ab7be0 100644
--- a/src/blob/handlers/BlobHandler.ts
+++ b/src/blob/handlers/BlobHandler.ts
@@ -1004,24 +1004,33 @@ export default class BlobHandler extends BaseHandler implements IBlobHandler {
       throw StorageErrorFactory.getBlobNotFound(context.contextId!);
     }
 
-    // Deserializer doesn't handle range header currently, manually parse range headers here
-    const rangesParts = deserializeRangeHeader(
-      context.request!.getHeader("range"),
-      context.request!.getHeader("x-ms-range")
-    );
-    const rangeStart = rangesParts[0];
-    let rangeEnd = rangesParts[1];
+    let rangesParts = undefined;
+    try {
+      // Deserializer doesn't handle range header currently, manually parse range headers here
+      rangesParts = deserializeRangeHeader(
+        context.request!.getHeader("range"),
+        context.request!.getHeader("x-ms-range")
+      );
+    } catch (err) {
+      this.logger.info(
+        `BlobHandler:downloadBlockBlobOrAppendBlob() Ignoring range request due to invalid content range: ${err.message}`,
+        context.contextId
+      );
+      // Ignoring range request as per RFC 9110, section 14.2
+    }
+    const rangeStart = rangesParts ? rangesParts[0] : 0;
+    let rangeEnd = rangesParts ? rangesParts[1] : Infinity;
 
     // Start Range is bigger than blob length
     if (rangeStart > blob.properties.contentLength!) {
-      throw StorageErrorFactory.getInvalidPageRange(context.contextId!);
+      throw StorageErrorFactory.getInvalidPageRange2(context.contextId!,`bytes */${blob.properties.contentLength}`);
     }
 
     // Will automatically shift request with longer data end than blob size to blob size
     if (rangeEnd + 1 >= blob.properties.contentLength!) {
       // report error is blob size is 0, and rangeEnd is specified but not 0 
       if (blob.properties.contentLength == 0 && rangeEnd !== 0 && rangeEnd !== Infinity) {
-        throw StorageErrorFactory.getInvalidPageRange2(context.contextId!);
+        throw StorageErrorFactory.getInvalidPageRange2(context.contextId!,`bytes */${blob.properties.contentLength}`);
       }
       else {
         rangeEnd = blob.properties.contentLength! - 1;
@@ -1065,10 +1074,7 @@ export default class BlobHandler extends BaseHandler implements IBlobHandler {
     }
 
     let contentRange: string | undefined;
-    if (
-      context.request!.getHeader("range") ||
-      context.request!.getHeader("x-ms-range")
-    ) {
+    if (rangesParts) {
       contentRange = `bytes ${rangeStart}-${rangeEnd}/${blob.properties
         .contentLength!}`;
     }
@@ -1145,14 +1151,14 @@ export default class BlobHandler extends BaseHandler implements IBlobHandler {
 
     // Start Range is bigger than blob length
     if (rangeStart > blob.properties.contentLength!) {
-      throw StorageErrorFactory.getInvalidPageRange(context.contextId!);
+      throw StorageErrorFactory.getInvalidPageRange2(context.contextId!,`bytes */${blob.properties.contentLength}`);
     }
 
     // Will automatically shift request with longer data end than blob size to blob size
     if (rangeEnd + 1 >= blob.properties.contentLength!) {
       // report error is blob size is 0, and rangeEnd is specified but not 0 
       if (blob.properties.contentLength == 0 && rangeEnd !== 0 && rangeEnd !== Infinity) {
-        throw StorageErrorFactory.getInvalidPageRange2(context.contextId!);
+        throw StorageErrorFactory.getInvalidPageRange2(context.contextId!,`bytes */${blob.properties.contentLength}`);
       }
       else {
         rangeEnd = blob.properties.contentLength! - 1;
diff --git a/src/blob/utils/utils.ts b/src/blob/utils/utils.ts
index 0f7fe89..46b6705 100644
--- a/src/blob/utils/utils.ts
+++ b/src/blob/utils/utils.ts
@@ -41,10 +41,10 @@ export async function streamToLocalFile(
 export function deserializeRangeHeader(
   rangeHeaderValue?: string,
   xMsRangeHeaderValue?: string
-): [number, number] {
+): [number, number] | undefined {
   const range = xMsRangeHeaderValue || rangeHeaderValue;
   if (!range) {
-    return [0, Infinity];
+    return undefined;
   }
 
   let parts = range.split("=");
@@ -94,8 +94,8 @@ export function deserializePageBlobRangeHeader(
   force512boundary = true
 ): [number, number] {
   const ranges = deserializeRangeHeader(rangeHeaderValue, xMsRangeHeaderValue);
-  const startInclusive = ranges[0];
-  const endInclusive = ranges[1];
+  const startInclusive = ranges ? ranges[0] : 0;
+  const endInclusive = ranges ? ranges[1] : Infinity;
 
   if (force512boundary && startInclusive % 512 !== 0) {
     throw new RangeError(
@@ -256,4 +256,4 @@ export function toBlobTags(input: TagContent[]): BlobTag[] {
       value: value
     }
   });
-}
\ No newline at end of file
+}


Test patch:
diff --git a/tests/blob/RequestPolicy/RangePolicyFactory.ts b/tests/blob/RequestPolicy/RangePolicyFactory.ts
new file mode 100644
index 0000000..a9a0842
--- /dev/null
+++ b/tests/blob/RequestPolicy/RangePolicyFactory.ts
@@ -0,0 +1,35 @@
+import { BaseRequestPolicy, WebResource } from "@azure/storage-blob";
+
+// Create a policy factory with create() method provided
+// In TypeScript, following factory class needs to implement Azure.RequestPolicyFactory type
+export default class RangePolicyFactory {
+  // Constructor to accept parameters
+  private range: string;
+
+  constructor(range: any) {
+    this.range = range;
+  }
+
+  // create() method needs to create a new RequestIDPolicy object
+  create(nextPolicy: any, options: any) {
+    return new RangePolicy(nextPolicy, options, this.range);
+  }
+}
+
+// Create a policy by extending from Azure.BaseRequestPolicy
+class RangePolicy extends BaseRequestPolicy {
+  private range: string;
+
+  constructor(nextPolicy: any, options: any, range: any) {
+    super(nextPolicy, options);
+    this.range = range;
+  }
+
+  // Customize HTTP requests and responses by overriding sendRequest
+  // Parameter request is Azure.WebResource type
+  async sendRequest(request: WebResource) {
+    request.headers.set("Range", `${this.range}`);
+
+    return await this._nextPolicy.sendRequest(request);
+  }
+}
diff --git a/tests/blob/apis/blob.test.ts b/tests/blob/apis/blob.test.ts
index f7a7847..dc02604 100644
--- a/tests/blob/apis/blob.test.ts
+++ b/tests/blob/apis/blob.test.ts
@@ -18,6 +18,7 @@ import {
   getUniqueName,
   sleep
 } from "../../testutils";
+import RangePolicyFactory from "../RequestPolicy/RangePolicyFactory";
 
 // Set true to enable debug log
 configLogger(false);
@@ -318,6 +319,62 @@ describe("BlobAPIs", () => {
     assert.fail();
   });
 
+  it("download invalid range @loki @sql", async () => {
+    const pipeline = newPipeline(
+      new StorageSharedKeyCredential(
+        EMULATOR_ACCOUNT_NAME,
+        EMULATOR_ACCOUNT_KEY
+      ),
+      {
+        retryOptions: { maxTries: 1 },
+        // Make sure socket is closed once the operation is done.
+        keepAliveOptions: { enable: false }
+      }
+    );
+    pipeline.factories.unshift(
+      new RangePolicyFactory("bytes=0--1")
+    );
+    const serviceClient = new BlobServiceClient(baseURL, pipeline);
+    const containerClient = serviceClient.getContainerClient(containerName);
+    const blobClient = containerClient.getBlobClient(blobName);
+
+    const result = await blobClient.download(0);
+    assert.deepStrictEqual(await bodyToString(result, content.length), content);
+    assert.equal(result.contentRange, undefined);
+    assert.equal(
+      result._response.request.headers.get("x-ms-client-request-id"),
+      result.clientRequestId
+    );
+  });
+
+  it("download partial range (via custom policy) @loki @sql", async () => {
+    const pipeline = newPipeline(
+      new StorageSharedKeyCredential(
+        EMULATOR_ACCOUNT_NAME,
+        EMULATOR_ACCOUNT_KEY
+      ),
+      {
+        retryOptions: { maxTries: 1 },
+        // Make sure socket is closed once the operation is done.
+        keepAliveOptions: { enable: false }
+      }
+    );
+    pipeline.factories.unshift(
+      new RangePolicyFactory("bytes=0-4")
+    );
+    const serviceClient = new BlobServiceClient(baseURL, pipeline);
+    const containerClient = serviceClient.getContainerClient(containerName);
+    const blobClient = containerClient.getBlobClient(blobName);
+
+    const result = await blobClient.download(0);
+    assert.deepStrictEqual(await bodyToString(result, content.length), content.substring(0, 5));
+    assert.equal(result.contentRange, `bytes 0-4/${content.length}`);
+    assert.equal(
+      result._response.request.headers.get("x-ms-client-request-id"),
+      result.clientRequestId
+    );
+  });
+
   it("get properties response should not set content-type @loki @sql", async () => {
     const blobURL404 = containerClient.getBlobClient("UN_EXIST_BLOB_");
     try {
diff --git a/tests/blob/apis/blockblob.test.ts b/tests/blob/apis/blockblob.test.ts
index d282a31..47b10dd 100644
--- a/tests/blob/apis/blockblob.test.ts
+++ b/tests/blob/apis/blockblob.test.ts
@@ -427,6 +427,7 @@ describe("BlockBlobAPIs", () => {
       await blockBlobClient.download(0, 3);
     } catch (error) {
       assert.deepStrictEqual(error.statusCode, 416);
+      assert.deepStrictEqual(error.response.headers.get("content-range"), 'bytes */0')
       return;
     }
     assert.fail();
diff --git a/tests/blob/apis/pageblob.test.ts b/tests/blob/apis/pageblob.test.ts
index 711d4bc..b2354b3 100644
--- a/tests/blob/apis/pageblob.test.ts
+++ b/tests/blob/apis/pageblob.test.ts
@@ -330,6 +330,7 @@ describe("PageBlobAPIs", () => {
       await pageBlobClient.download(0, 3);
     } catch (error) {
       assert.deepStrictEqual(error.statusCode, 416);
+      assert.deepStrictEqual(error.response.headers.get("content-range"), 'bytes */0')
       return;
     }
     assert.fail();