From de6cf5bf8a5cdea0776377b58a3c53010a78ce34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:34:28 +0000 Subject: [PATCH 1/7] Bump form-data Bumps and [form-data](https://github.com/form-data/form-data). These dependencies needed to be updated together. Updates `form-data` from 2.5.5 to 2.5.6 - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v2.5.5...v2.5.6) Updates `form-data` from 4.0.5 to 4.0.6 - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v2.5.5...v2.5.6) --- updated-dependencies: - dependency-name: form-data dependency-version: 2.5.6 dependency-type: indirect - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad5f26f29..144882da2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2081,16 +2081,16 @@ } }, "node_modules/axios/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4844,13 +4844,15 @@ } }, "node_modules/form-data": { - "version": "2.5.5", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", + "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" }, @@ -5166,7 +5168,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" From e6bd3c0d037690a1b5f2a2b27026cea5899dd7a2 Mon Sep 17 00:00:00 2001 From: Akanksha Jain Date: Mon, 6 Jul 2026 14:22:44 +0530 Subject: [PATCH 2/7] Bumped undici version (#2666) * Bumped undici version * Enforced undici through overrides --- ChangeLog.md | 1 + package-lock.json | 6 +++--- package.json | 5 ++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 96358563f..bd53d4466 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -16,6 +16,7 @@ General: - Migrate Windows/Linux binary build flow from pkg/pkg-fetch to Node.js SEA (`esbuild` + `postject`), with build-audit checks. - Add a temporary non-blocking legacy Node 16 smoke lane in Azure Pipelines for transition monitoring. - Address dependency vulnerabilities by upgrading packages: `@azure/ms-rest-js` to 2.x, `axios` to 1.x, `tedious` to 18.x, and `@azure/storage-blob`/`@azure/storage-queue` to 12.28.x/12.27.x. +- Enforce `undici` version `^7.28.0` via npm `overrides` to address an SFI security item. - Remove the deprecated `azure-storage` dev dependency and migrate the affected queue and table test suites to `@azure/data-tables` and other modern Azure SDK clients (adds `@azure/identity`). - Standardize binary data handling on `Uint8Array` instead of `Buffer` (e.g. MD5 hashes, Content-MD5, internal buffer conversions). diff --git a/package-lock.json b/package-lock.json index 144882da2..c7fde54b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8467,9 +8467,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", - "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e841bb2e5..29497be40 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,9 @@ "typescript": "^5.0.3", "vsce": "^2.7.0" }, + "overrides": { + "undici": "^7.28.0" + }, "activationEvents": [ "onStartupFinished" ], @@ -351,4 +354,4 @@ "url": "https://github.com/azure/azurite/issues" }, "homepage": "https://github.com/azure/azurite#readme" -} +} \ No newline at end of file From ce60dc038d00300a30c6adb174fbf952cf05563b Mon Sep 17 00:00:00 2001 From: Akanksha Jain Date: Mon, 6 Jul 2026 23:42:26 +0530 Subject: [PATCH 3/7] Fixed Bad exe issue (#2668) --- ChangeLog.md | 1 + scripts/buildExe.js | 67 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index bd53d4466..d0fc58314 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -19,6 +19,7 @@ General: - Enforce `undici` version `^7.28.0` via npm `overrides` to address an SFI security item. - Remove the deprecated `azure-storage` dev dependency and migrate the affected queue and table test suites to `@azure/data-tables` and other modern Azure SDK clients (adds `@azure/identity`). - Standardize binary data handling on `Uint8Array` instead of `Buffer` (e.g. MD5 hashes, Content-MD5, internal buffer conversions). +- Fix Windows SEA executable build producing a corrupted/bad `.exe` by stripping the Authenticode signature from the copied Node.js binary before injecting the SEA blob via `postject`. Blob: diff --git a/scripts/buildExe.js b/scripts/buildExe.js index 013503467..3f33b377c 100644 --- a/scripts/buildExe.js +++ b/scripts/buildExe.js @@ -66,6 +66,7 @@ async function build() { run(process.execPath, ['--experimental-sea-config', seaConfigPath]); cpSync(process.execPath, outputExe); + removeAuthenticodeSignature(outputExe); await rcedit(outputExe, { 'version-string': { @@ -136,3 +137,69 @@ function run(command, args) { throw new Error(`Command failed with exit code ${result.status}: ${command} ${args.join(' ')}`); } } + +function removeAuthenticodeSignature(binaryPath) { + const bytes = fs.readFileSync(binaryPath); + if (bytes.length < 0x40) { + throw new Error(`File is too small to be a valid PE executable: ${binaryPath}`); + } + + const peOffset = bytes.readUInt32LE(0x3c); + if (peOffset + 26 > bytes.length) { + throw new Error(`Invalid PE header offset ${peOffset} for file length ${bytes.length}: ${binaryPath}`); + } + + if (bytes.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') { + throw new Error(`Missing PE signature at offset ${peOffset}: ${binaryPath}`); + } + + const optionalHeaderStart = peOffset + 24; + const optionalHeaderMagic = bytes.readUInt16LE(optionalHeaderStart); + + let dataDirectoryStart; + if (optionalHeaderMagic === 0x10b) { + dataDirectoryStart = optionalHeaderStart + 96; + } else if (optionalHeaderMagic === 0x20b) { + dataDirectoryStart = optionalHeaderStart + 112; + } else { + throw new Error(`Unsupported PE optional header magic: 0x${optionalHeaderMagic.toString(16)}`); + } + + const securityDirectoryEntry = dataDirectoryStart + 8 * 4; + if (securityDirectoryEntry + 8 > bytes.length) { + throw new Error( + `PE security directory entry is out of range: offset=${securityDirectoryEntry}, fileLength=${bytes.length}` + ); + } + + const certTableFileOffset = bytes.readUInt32LE(securityDirectoryEntry); + const certTableSize = bytes.readUInt32LE(securityDirectoryEntry + 4); + + // Clear IMAGE_DIRECTORY_ENTRY_SECURITY so no stale certificate pointer remains after postject. + bytes.writeUInt32LE(0, securityDirectoryEntry); + bytes.writeUInt32LE(0, securityDirectoryEntry + 4); + + if (certTableSize === 0) { + fs.writeFileSync(binaryPath, bytes); + return; + } + + if ( + certTableFileOffset === 0 || + certTableFileOffset > bytes.length || + certTableSize > bytes.length - certTableFileOffset + ) { + throw new Error( + `Invalid PE certificate table range: offset=${certTableFileOffset}, size=${certTableSize}, fileLength=${bytes.length}` + ); + } + + bytes.fill(0, certTableFileOffset, certTableFileOffset + certTableSize); + + if (certTableSize > 0 && certTableFileOffset + certTableSize === bytes.length) { + fs.writeFileSync(binaryPath, bytes.subarray(0, certTableFileOffset)); + return; + } + + fs.writeFileSync(binaryPath, bytes); +} From 1978d9d52aa6d3a07567eddd39059f538981a85b Mon Sep 17 00:00:00 2001 From: Akanksha Jain Date: Mon, 13 Jul 2026 19:25:22 +0530 Subject: [PATCH 4/7] Fixed constant identifiable credential Security Issue (#2671) * Fixed constant identifiable credential Security Issue * Updated changelog * Updated copilot review comment --- ChangeLog.md | 2 ++ src/blob/utils/constants.ts | 15 ++++++--------- src/blob/utils/utils.ts | 4 ++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index d0fc58314..d06a5d2d5 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -20,6 +20,8 @@ General: - Remove the deprecated `azure-storage` dev dependency and migrate the affected queue and table test suites to `@azure/data-tables` and other modern Azure SDK clients (adds `@azure/identity`). - Standardize binary data handling on `Uint8Array` instead of `Buffer` (e.g. MD5 hashes, Content-MD5, internal buffer conversions). - Fix Windows SEA executable build producing a corrupted/bad `.exe` by stripping the Authenticode signature from the copied Node.js binary before injecting the SEA blob via `postject`. +- Replace the hardcoded user delegation signing key literal with a deterministic internal signing seed to avoid VS Code extension publish secret-scan blocks while preserving stable user delegation SAS behavior. + Blob: diff --git a/src/blob/utils/constants.ts b/src/blob/utils/constants.ts index 3af115f05..40319eea4 100644 --- a/src/blob/utils/constants.ts +++ b/src/blob/utils/constants.ts @@ -172,15 +172,12 @@ export const VALID_BLOB_AUDIENCES = [ export const HTTP_LINE_ENDING = "\r\n"; export const HTTP_HEADER_DELIMITER = ": "; -// Signing key used internally by the emulator to produce user delegation -// keys. This is not a real credential: clients never need to know it (they -// receive the issued key from the server), and it must stay stable so the -// same value is used across instances and restarts when issuing and -// validating user delegation SAS. The value is intentionally fixed. -// codeql[js/hardcoded-credentials] -// lgtm[js/hardcoded-credentials] -export const USERDELEGATIONKEY_BASIC_KEY = - "I17GKLvcJUossaebtsEDZZ2RJ8GNLwLH4m7hRMxbVbkx6wNIRAABj4Rtw0FBhFuEAgmbL4gFMzUw+AStz9Sqdg=="; +// Deterministic, non-secret string used directly as HMAC key material by +// getUserDelegationKeyValue() when producing user delegation key values. +// It must stay stable so signatures remain consistent across instances and +// restarts. +export const USERDELEGATIONKEY_SIGNING_SEED = + "azurite-user-delegation-signing-seed-v1"; export const AUTHENTICATION_BEARERTOKEN_REQUIRED = "Only authentication scheme Bearer is supported"; diff --git a/src/blob/utils/utils.ts b/src/blob/utils/utils.ts index 46b6705fe..c34e4f4bf 100644 --- a/src/blob/utils/utils.ts +++ b/src/blob/utils/utils.ts @@ -1,7 +1,7 @@ import { createHmac } from "crypto"; import { createWriteStream, PathLike } from "fs"; import StorageErrorFactory from "../errors/StorageErrorFactory"; -import { USERDELEGATIONKEY_BASIC_KEY } from "./constants"; +import { USERDELEGATIONKEY_SIGNING_SEED } from "./constants"; import { BlobTag, BlobTags } from "@azure/storage-blob"; import { TagContent } from "../persistence/QueryInterpreter/QueryNodes/IQueryNode"; @@ -164,7 +164,7 @@ export function getUserDelegationKeyValue( signedVersion ].join("\n"); - return createHmac("sha256", USERDELEGATIONKEY_BASIC_KEY).update(stringToSign, "utf8").digest("base64"); + return createHmac("sha256", USERDELEGATIONKEY_SIGNING_SEED).update(stringToSign, "utf8").digest("base64"); } export function getBlobTagsCount( From 30fb826a05ed50b0138dc6161b2faba49ed6f6b2 Mon Sep 17 00:00:00 2001 From: Akanksha Jain Date: Fri, 17 Jul 2026 18:04:28 +0530 Subject: [PATCH 5/7] Added few scripts for testing (#2674) --- .vscode/settings.json | 5 +- scripts/azurite-cli.ps1 | 229 +++++++++++++++++++++++++++ scripts/azurite-cli.sh | 233 ++++++++++++++++++++++++++++ scripts/azurite-rest.ps1 | 325 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 scripts/azurite-cli.ps1 create mode 100644 scripts/azurite-cli.sh create mode 100644 scripts/azurite-rest.ps1 diff --git a/.vscode/settings.json b/.vscode/settings.json index 0143bafd2..d9d9d49d5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "editor.tabSize": 2, "editor.formatOnSave": true, - "typescript.tsdk": "node_modules/typescript/lib" -} + "typescript.tsdk": "node_modules/typescript/lib", + "sarif-viewer.connectToGithubCodeScanning": "on" +} \ No newline at end of file diff --git a/scripts/azurite-cli.ps1 b/scripts/azurite-cli.ps1 new file mode 100644 index 000000000..f7645b21f --- /dev/null +++ b/scripts/azurite-cli.ps1 @@ -0,0 +1,229 @@ +# azurite-cli.ps1 +# Reusable helpers for testing a local Azurite instance with the official Azure CLI (`az storage`). +# +# Quick start: +# . .\scripts\azurite-cli.ps1 # dot-source to load the functions into your session +# Use-Azurite # point the Azure CLI at local Azurite (sets connection string) +# az storage container list -o table +# +# After Use-Azurite, every `az storage ...` command targets Azurite automatically. +# +# Optional convenience wrappers (thin shortcuts over `az storage`): +# Test-AzuriteBlob # runs a full blob lifecycle smoke test +# Test-AzuriteQueue # runs a full queue lifecycle smoke test +# Test-AzuriteTable # runs a full table lifecycle smoke test +# Test-AzuriteAll # runs blob + queue + table smoke tests +# New-AzuriteContainer mycontainer +# Send-AzuriteBlob mycontainer ./file.txt myblob.txt +# Get-AzuriteBlobs mycontainer +# Receive-AzuriteBlob mycontainer myblob.txt ./out.txt + +# --- Default Azurite emulator credentials (well-known, safe for local dev only) --- +$script:AzCliAccount = 'devstoreaccount1' +$script:AzCliKey = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==' +$script:AzCliHost = '127.0.0.1' + +# Populated by Use-Azurite; passed explicitly via --connection-string on every `az` call +# so it works even when `az` is the Windows az.exe invoked from WSL (env vars don't cross). +$script:AzCliCS = $null + +function Use-Azurite { + <# + Points the Azure CLI at the local Azurite instance for the current shell session. + Builds the connection string (covers Blob, Queue and Table), stores it in + $script:AzCliCS, and also exports AZURE_STORAGE_CONNECTION_STRING for ad-hoc commands. + #> + [CmdletBinding()] + param( + [string]$AzHost = $script:AzCliHost, + [int]$BlobPort = 10000, + [int]$QueuePort = 10001, + [int]$TablePort = 10002 + ) + + $cs = @( + "DefaultEndpointsProtocol=http" + "AccountName=$script:AzCliAccount" + "AccountKey=$script:AzCliKey" + "BlobEndpoint=http://${AzHost}:$BlobPort/$script:AzCliAccount" + "QueueEndpoint=http://${AzHost}:$QueuePort/$script:AzCliAccount" + "TableEndpoint=http://${AzHost}:$TablePort/$script:AzCliAccount" + ) -join ';' + + $script:AzCliCS = $cs + $env:AZURE_STORAGE_CONNECTION_STRING = $cs + Write-Host "Azure CLI is now pointed at Azurite ($AzHost blob:$BlobPort queue:$QueuePort table:$TablePort)." -ForegroundColor Green + Write-Host "Helpers pass --connection-string explicitly, so this works in WSL too." -ForegroundColor DarkGray +} + +function Clear-Azurite { + <# Removes the Azurite connection string so `az` targets real Azure / your login again. #> + $script:AzCliCS = $null + Remove-Item Env:AZURE_STORAGE_CONNECTION_STRING -ErrorAction SilentlyContinue + Write-Host "Cleared Azurite connection string." -ForegroundColor Yellow +} + +function Assert-AzuriteContext { + if (-not $script:AzCliCS) { + Write-Host "Connection string not set. Running Use-Azurite for you..." -ForegroundColor Yellow + Use-Azurite + } +} + +# --- Thin convenience wrappers (entirely optional; plain `az storage` works too) --- + +function New-AzuriteContainer { + param([Parameter(Mandatory)][string]$Name) + Assert-AzuriteContext + az storage container create --name $Name --connection-string $script:AzCliCS --output table +} + +function Send-AzuriteBlob { + param( + [Parameter(Mandatory)][string]$Container, + [Parameter(Mandatory)][string]$File, + [string]$Name + ) + Assert-AzuriteContext + if (-not $Name) { $Name = Split-Path $File -Leaf } + az storage blob upload --container-name $Container --name $Name --file $File --overwrite --connection-string $script:AzCliCS --output table +} + +function Get-AzuriteBlobs { + param([Parameter(Mandatory)][string]$Container) + Assert-AzuriteContext + az storage blob list --container-name $Container --connection-string $script:AzCliCS --output table +} + +function Receive-AzuriteBlob { + param( + [Parameter(Mandatory)][string]$Container, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Destination + ) + Assert-AzuriteContext + az storage blob download --container-name $Container --name $Name --file $Destination --connection-string $script:AzCliCS --output none + Write-Host "Downloaded '$Name' -> '$Destination'" -ForegroundColor Green +} + +function Test-AzuriteBlob { + <# End-to-end blob smoke test: create container, upload, list, download, verify, cleanup. #> + [CmdletBinding()] + param([string]$Container = "smoketest$((Get-Random -Maximum 9999))") + + Assert-AzuriteContext + $tmpDir = [System.IO.Path]::GetTempPath() # cross-platform (Windows/Linux/macOS) + $tmpUp = Join-Path $tmpDir "azurite-up-$([guid]::NewGuid()).txt" + $tmpDown = Join-Path $tmpDir "azurite-down-$([guid]::NewGuid()).txt" + $content = "azurite smoke test @ $(Get-Date -Format o)" + + try { + Write-Host "`n[1/5] Create container '$Container'" -ForegroundColor Cyan + az storage container create --name $Container --connection-string $script:AzCliCS --output table + + Write-Host "`n[2/5] Upload blob 'hello.txt'" -ForegroundColor Cyan + Set-Content -Path $tmpUp -Value $content -Encoding ascii + az storage blob upload --container-name $Container --name hello.txt --file $tmpUp --overwrite --connection-string $script:AzCliCS --output table + + Write-Host "`n[3/5] List blobs" -ForegroundColor Cyan + az storage blob list --container-name $Container --connection-string $script:AzCliCS --output table + + Write-Host "`n[4/5] Download blob" -ForegroundColor Cyan + az storage blob download --container-name $Container --name hello.txt --file $tmpDown --connection-string $script:AzCliCS --output none + $roundTrip = (Get-Content $tmpDown -Raw).Trim() + if ($roundTrip -eq $content) { + Write-Host "Round-trip OK: content matches." -ForegroundColor Green + } else { + Write-Host "Round-trip MISMATCH!`n expected: $content`n got: $roundTrip" -ForegroundColor Red + } + + Write-Host "`n[5/5] Cleanup (delete container)" -ForegroundColor Cyan + az storage container delete --name $Container --connection-string $script:AzCliCS --output table + Write-Host "`nSmoke test complete." -ForegroundColor Green + } + finally { + Remove-Item $tmpUp, $tmpDown -ErrorAction SilentlyContinue + } +} + +function Test-AzuriteQueue { + <# End-to-end queue smoke test: create queue, enqueue, peek, verify, cleanup. #> + [CmdletBinding()] + param([string]$Queue = "smoketestq$((Get-Random -Maximum 99999))") + + Assert-AzuriteContext + $content = "azurite queue smoke test @ $(Get-Date -Format o)" + + Write-Host "`n[1/4] Create queue '$Queue'" -ForegroundColor Cyan + az storage queue create --name $Queue --connection-string $script:AzCliCS --output table + + Write-Host "`n[2/4] Enqueue message" -ForegroundColor Cyan + az storage message put --queue-name $Queue --content $content --connection-string $script:AzCliCS --output table + + Write-Host "`n[3/4] Peek and verify message" -ForegroundColor Cyan + $roundTrip = (az storage message peek --queue-name $Queue --num-messages 1 --connection-string $script:AzCliCS --query "[0].content" -o tsv).Trim() + if ($roundTrip -eq $content) { + Write-Host "Round-trip OK: message content matches." -ForegroundColor Green + } + else { + Write-Host "Round-trip MISMATCH!`n expected: $content`n got: $roundTrip" -ForegroundColor Red + } + + Write-Host "`n[4/4] Cleanup (delete queue)" -ForegroundColor Cyan + az storage queue delete --name $Queue --connection-string $script:AzCliCS --output table + Write-Host "`nQueue smoke test complete." -ForegroundColor Green +} + +function Test-AzuriteTable { + <# End-to-end table smoke test: create table, insert entity, query, verify, cleanup. #> + [CmdletBinding()] + param([string]$Table = "smoketab$((Get-Random -Maximum 99999))") + + Assert-AzuriteContext + $partitionKey = "pk$((Get-Random -Maximum 99999))" + $rowKey = "rk$((Get-Random -Maximum 99999))" + $value = "azurite_table_smoke_$(Get-Date -Format 'yyyyMMddTHHmmssZ')" + + Write-Host "`n[1/4] Create table '$Table'" -ForegroundColor Cyan + az storage table create --name $Table --connection-string $script:AzCliCS --output table + + Write-Host "`n[2/4] Insert entity" -ForegroundColor Cyan + az storage entity insert --table-name $Table --entity PartitionKey=$partitionKey RowKey=$rowKey Message=$value --connection-string $script:AzCliCS --output table + + Write-Host "`n[3/4] Query and verify entity" -ForegroundColor Cyan + $roundTrip = (az storage entity query --table-name $Table --filter "PartitionKey eq '$partitionKey' and RowKey eq '$rowKey'" --select Message --connection-string $script:AzCliCS --query "items[0].Message" -o tsv).Trim() + if ($roundTrip -eq $value) { + Write-Host "Round-trip OK: entity value matches." -ForegroundColor Green + } + else { + Write-Host "Round-trip MISMATCH!`n expected: $value`n got: $roundTrip" -ForegroundColor Red + } + + Write-Host "`n[4/4] Cleanup (delete table)" -ForegroundColor Cyan + az storage table delete --name $Table --connection-string $script:AzCliCS --output table + Write-Host "`nTable smoke test complete." -ForegroundColor Green +} + +function Test-AzuriteAll { + <# Full Azurite smoke test across Blob, Queue and Table. #> + [CmdletBinding()] + param() + + Assert-AzuriteContext + Test-AzuriteBlob + Test-AzuriteQueue + Test-AzuriteTable +} + +# When this script is RUN directly (.\scripts\azurite-cli.ps1) it auto-configures the +# connection string and runs full blob/queue/table smoke tests - no extra input needed. +# When it is DOT-SOURCED (. .\scripts\azurite-cli.ps1) it just loads the functions. +if ($MyInvocation.InvocationName -ne '.') { + Use-Azurite + Test-AzuriteAll +} +else { + Write-Host "azurite-cli helpers loaded. Run 'Use-Azurite' to begin, then any 'az storage ...' command." -ForegroundColor DarkGray + Write-Host "Try smoke tests with: Test-AzuriteBlob, Test-AzuriteQueue, Test-AzuriteTable" -ForegroundColor DarkGray + Write-Host "Run all three with: Test-AzuriteAll" -ForegroundColor DarkGray +} diff --git a/scripts/azurite-cli.sh b/scripts/azurite-cli.sh new file mode 100644 index 000000000..f48b09a02 --- /dev/null +++ b/scripts/azurite-cli.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# azurite-cli.sh +# Reusable helpers for testing a local Azurite instance with the official Azure CLI (`az storage`). +# Bash equivalent of scripts/azurite-cli.ps1 — works on Linux, macOS, and WSL (no PowerShell needed). +# +# Quick start: +# source ./scripts/azurite-cli.sh # load the functions into your current shell +# use_azurite # point the Azure CLI at local Azurite (sets connection string) +# az storage container list -o table +# +# After use_azurite, every `az storage ...` command targets Azurite automatically. +# +# Optional convenience wrappers (thin shortcuts over `az storage`): +# test_azurite_blob # runs a full blob lifecycle smoke test +# test_azurite_queue # runs a full queue lifecycle smoke test +# test_azurite_table # runs a full table lifecycle smoke test +# test_azurite_all # runs blob + queue + table smoke tests +# new_azurite_container mycontainer +# send_azurite_blob mycontainer ./file.txt myblob.txt +# get_azurite_blobs mycontainer +# receive_azurite_blob mycontainer myblob.txt ./out.txt + +# --- Default Azurite emulator credentials (well-known, safe for local dev only) --- +AZ_CLI_ACCOUNT='devstoreaccount1' +AZ_CLI_KEY='Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==' +AZ_CLI_HOST='127.0.0.1' + +# Populated by use_azurite; consumed (explicitly) by every wrapper below. +# NOTE: in WSL `az` is often the Windows az.exe, which does NOT see Linux exported +# env vars. So helpers pass --connection-string "$AZ_CLI_CS" explicitly on every call. +AZ_CLI_CS='' + +use_azurite() { + # Builds the Azurite connection string (covers Blob, Queue and Table) and stores it + # in AZ_CLI_CS. Also exports AZURE_STORAGE_CONNECTION_STRING for ad-hoc `az` commands. + # Optional args: host blobPort queuePort tablePort + local az_host="${1:-$AZ_CLI_HOST}" + local blob_port="${2:-10000}" + local queue_port="${3:-10001}" + local table_port="${4:-10002}" + + AZ_CLI_CS="DefaultEndpointsProtocol=http;AccountName=${AZ_CLI_ACCOUNT};AccountKey=${AZ_CLI_KEY};BlobEndpoint=http://${az_host}:${blob_port}/${AZ_CLI_ACCOUNT};QueueEndpoint=http://${az_host}:${queue_port}/${AZ_CLI_ACCOUNT};TableEndpoint=http://${az_host}:${table_port}/${AZ_CLI_ACCOUNT};" + export AZURE_STORAGE_CONNECTION_STRING="$AZ_CLI_CS" + + echo "Azure CLI is now pointed at Azurite (${az_host} blob:${blob_port} queue:${queue_port} table:${table_port})." + echo "Helpers pass --connection-string explicitly, so this works in WSL too." +} + +clear_azurite() { + # Removes the Azurite connection string so `az` targets real Azure / your login again. + AZ_CLI_CS='' + unset AZURE_STORAGE_CONNECTION_STRING + echo "Cleared Azurite connection string." +} + +assert_azurite_context() { + if [ -z "${AZ_CLI_CS:-}" ]; then + echo "Connection string not set. Running use_azurite for you..." + use_azurite + fi +} + +# --- Thin convenience wrappers (entirely optional; plain `az storage` works too) --- + +new_azurite_container() { + # usage: new_azurite_container + assert_azurite_context + az storage container create --name "$1" --connection-string "$AZ_CLI_CS" --output table +} + +send_azurite_blob() { + # usage: send_azurite_blob [blob-name] + assert_azurite_context + local container="$1" file="$2" name="${3:-}" + if [ -z "$name" ]; then name="$(basename "$file")"; fi + az storage blob upload --container-name "$container" --name "$name" --file "$file" --overwrite --connection-string "$AZ_CLI_CS" --output table +} + +get_azurite_blobs() { + # usage: get_azurite_blobs + assert_azurite_context + az storage blob list --container-name "$1" --connection-string "$AZ_CLI_CS" --output table +} + +receive_azurite_blob() { + # usage: receive_azurite_blob + assert_azurite_context + az storage blob download --container-name "$1" --name "$2" --file "$3" --connection-string "$AZ_CLI_CS" --output none + echo "Downloaded '$2' -> '$3'" +} + +test_azurite_blob() { + # End-to-end blob smoke test: create container, upload, list, download, verify, cleanup. + # usage: test_azurite_blob [container-name] + assert_azurite_context + + local container="${1:-smoketest$RANDOM}" + # Use the current directory for temp files so the path is valid for both a + # Linux-native az and a Windows az.exe invoked from WSL (/mnt/c/...). + local tmp_up="./.azurite-up-$$-$RANDOM.txt" + local tmp_down="./.azurite-down-$$-$RANDOM.txt" + local content="azurite smoke test @ $(date -u +%Y-%m-%dT%H:%M:%SZ)" + + # shellcheck disable=SC2317 + _cleanup() { rm -f "$tmp_up" "$tmp_down"; } + trap _cleanup RETURN + + echo "" + echo "[1/5] Create container '$container'" + az storage container create --name "$container" --connection-string "$AZ_CLI_CS" --output table + + echo "" + echo "[2/5] Upload blob 'hello.txt'" + printf '%s' "$content" > "$tmp_up" + az storage blob upload --container-name "$container" --name hello.txt --file "$tmp_up" --overwrite --connection-string "$AZ_CLI_CS" --output table + + echo "" + echo "[3/5] List blobs" + az storage blob list --container-name "$container" --connection-string "$AZ_CLI_CS" --output table + + echo "" + echo "[4/5] Download blob" + az storage blob download --container-name "$container" --name hello.txt --file "$tmp_down" --connection-string "$AZ_CLI_CS" --output none + local round_trip + round_trip="$(cat "$tmp_down" 2>/dev/null)" + if [ "$round_trip" = "$content" ]; then + echo "Round-trip OK: content matches." + else + echo "Round-trip MISMATCH!" + echo " expected: $content" + echo " got: $round_trip" + fi + + echo "" + echo "[5/5] Cleanup (delete container)" + az storage container delete --name "$container" --connection-string "$AZ_CLI_CS" --output table + echo "" + echo "Smoke test complete." +} + +test_azurite_queue() { + # End-to-end queue smoke test: create queue, enqueue, peek, verify, cleanup. + # usage: test_azurite_queue [queue-name] + assert_azurite_context + + local queue="${1:-smoketestq$RANDOM}" + local content="azurite queue smoke test @ $(date -u +%Y-%m-%dT%H:%M:%SZ)" + + echo "" + echo "[1/4] Create queue '$queue'" + az storage queue create --name "$queue" --connection-string "$AZ_CLI_CS" --output table + + echo "" + echo "[2/4] Enqueue message" + az storage message put --queue-name "$queue" --content "$content" --connection-string "$AZ_CLI_CS" --output table + + echo "" + echo "[3/4] Peek and verify message" + local round_trip + round_trip="$(az storage message peek --queue-name "$queue" --num-messages 1 --connection-string "$AZ_CLI_CS" --query "[0].content" -o tsv 2>/dev/null | tr -d '\r')" + if [ "$round_trip" = "$content" ]; then + echo "Round-trip OK: message content matches." + else + echo "Round-trip MISMATCH!" + echo " expected: $content" + echo " got: $round_trip" + fi + + echo "" + echo "[4/4] Cleanup (delete queue)" + az storage queue delete --name "$queue" --connection-string "$AZ_CLI_CS" --output table + echo "" + echo "Queue smoke test complete." +} + +test_azurite_table() { + # End-to-end table smoke test: create table, insert entity, query, verify, cleanup. + # usage: test_azurite_table [table-name] + assert_azurite_context + + local table="${1:-smoketab$RANDOM}" + local partition_key="pk$RANDOM" + local row_key="rk$RANDOM" + local value="azurite_table_smoke_$(date -u +%Y%m%dT%H%M%SZ)" + + echo "" + echo "[1/4] Create table '$table'" + az storage table create --name "$table" --connection-string "$AZ_CLI_CS" --output table + + echo "" + echo "[2/4] Insert entity" + az storage entity insert --table-name "$table" --entity PartitionKey="$partition_key" RowKey="$row_key" Message="$value" --connection-string "$AZ_CLI_CS" --output table + + echo "" + echo "[3/4] Query and verify entity" + local round_trip + round_trip="$(az storage entity query --table-name "$table" --filter "PartitionKey eq '$partition_key' and RowKey eq '$row_key'" --select Message --connection-string "$AZ_CLI_CS" --query "items[0].Message" -o tsv 2>/dev/null | tr -d '\r')" + if [ "$round_trip" = "$value" ]; then + echo "Round-trip OK: entity value matches." + else + echo "Round-trip MISMATCH!" + echo " expected: $value" + echo " got: $round_trip" + fi + + echo "" + echo "[4/4] Cleanup (delete table)" + az storage table delete --name "$table" --connection-string "$AZ_CLI_CS" --output table + echo "" + echo "Table smoke test complete." +} + +test_azurite_all() { + # Full Azurite smoke test across Blob, Queue and Table. + # usage: test_azurite_all + assert_azurite_context + + test_azurite_blob + test_azurite_queue + test_azurite_table +} + +# When this script is SOURCED (source ./scripts/azurite-cli.sh) it just loads the functions. +# When it is RUN directly (./scripts/azurite-cli.sh) it auto-configures the connection +# string and runs full blob/queue/table smoke tests - no extra input needed. +if [ -n "${BASH_SOURCE:-}" ] && [ "${BASH_SOURCE[0]}" != "${0}" ]; then + echo "azurite-cli helpers loaded. Run 'use_azurite' to begin, then any 'az storage ...' command." + echo "Try smoke tests with: test_azurite_blob, test_azurite_queue, test_azurite_table" + echo "Run all three with: test_azurite_all" +else + use_azurite + test_azurite_all +fi diff --git a/scripts/azurite-rest.ps1 b/scripts/azurite-rest.ps1 new file mode 100644 index 000000000..2949d0347 --- /dev/null +++ b/scripts/azurite-rest.ps1 @@ -0,0 +1,325 @@ +# azurite-rest.ps1 +# Helper to fire raw REST calls against Azurite using Shared Key (HMAC-SHA256) auth. +# +# Usage examples (run from a PowerShell prompt after dot-sourcing this file): +# . .\scripts\azurite-rest.ps1 +# Invoke-Azurite -Service blob -Method PUT -Resource 'mycontainer' -Query @{ restype = 'container' } +# Invoke-Azurite -Service blob -Method GET -Resource '' -Query @{ comp = 'list' } # list containers +# Invoke-Azurite -Service blob -Method PUT -Resource 'mycontainer/hello.txt' -Body 'Hello Azurite' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } +# Invoke-Azurite -Service blob -Method GET -Resource 'mycontainer/hello.txt' +# Invoke-Azurite -Service blob -Method GET -Resource 'mycontainer' -Query @{ restype = 'container'; comp = 'list' } # list blobs +# Invoke-Azurite -Service blob -Method DELETE -Resource 'mycontainer/hello.txt' +# +# Invoke-Azurite -Service queue -Method PUT -Resource 'myqueue' +# Invoke-Azurite -Service queue -Method POST -Resource 'myqueue/messages' -Body 'aGVsbG8=' -Headers @{ 'Content-Type' = 'application/xml' } +# +# Table service (different signature scheme - use Invoke-AzuriteTable): +# Invoke-AzuriteTable -Method POST -Resource 'Tables' -Body '{"TableName":"mytable"}' +# Invoke-AzuriteTable -Method GET -Resource 'Tables' +# Invoke-AzuriteTable -Method POST -Resource 'mytable' -Body '{"PartitionKey":"p1","RowKey":"r1","Name":"Alice"}' +# Invoke-AzuriteTable -Method GET -Resource "mytable(PartitionKey='p1',RowKey='r1')" +# Invoke-AzuriteTable -Method DELETE -Resource "mytable(PartitionKey='p1',RowKey='r1')" -Headers @{ 'If-Match' = '*' } + +$script:AzAccount = 'devstoreaccount1' +$script:AzKey = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==' +$script:AzPorts = @{ blob = 10000; queue = 10001; table = 10002 } + +function Invoke-Azurite { + param( + [ValidateSet('blob', 'queue', 'table')] + [string]$Service = 'blob', + + [string]$Method = 'GET', + + # Path after the account, e.g. 'mycontainer/hello.txt' + [string]$Resource = '', + + # Query string parameters as a hashtable, e.g. @{ restype = 'container' } + [hashtable]$Query = @{}, + + # Extra request headers (e.g. x-ms-blob-type) + [hashtable]$Headers = @{}, + + # Request body (string) + [string]$Body = '' + ) + + $port = $script:AzPorts[$Service] + $apiVersion = '2025-05-05' + $date = [DateTime]::UtcNow.ToString('R') + + # Build canonicalized query string (sorted, lowercased keys) + $sortedKeys = $Query.Keys | Sort-Object + $queryParts = foreach ($k in $sortedKeys) { "$($k.ToLowerInvariant()):$($Query[$k])" } + $canonicalizedQuery = ($queryParts -join "`n") + + $uriQuery = '' + if ($Query.Count -gt 0) { + $pairs = foreach ($k in $sortedKeys) { + "$([uri]::EscapeDataString($k))=$([uri]::EscapeDataString([string]$Query[$k]))" + } + $uriQuery = '?' + ($pairs -join '&') + } + + $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($Body) + $contentLen = if ($bodyBytes.Length -gt 0) { "$($bodyBytes.Length)" } else { '' } + $contentType = if ($Headers.ContainsKey('Content-Type')) { $Headers['Content-Type'] } else { '' } + + # Canonicalized headers: all x-ms-* headers, lowercased, sorted, joined with \n + $msHeaders = @{ + 'x-ms-date' = $date + 'x-ms-version' = $apiVersion + } + foreach ($h in $Headers.Keys) { + if ($h.ToLowerInvariant().StartsWith('x-ms-')) { $msHeaders[$h.ToLowerInvariant()] = $Headers[$h] } + } + $canonHeaders = ($msHeaders.Keys | Sort-Object | ForEach-Object { "$($_):$($msHeaders[$_])" }) -join "`n" + + # Canonicalized resource. NOTE: for the emulator the account name appears twice. + $resourcePath = "/$script:AzAccount/$script:AzAccount" + if ($Resource) { $resourcePath += "/$Resource" } + $canonResource = $resourcePath + if ($canonicalizedQuery) { $canonResource += "`n$canonicalizedQuery" } + + # String-to-sign (Blob/Queue Shared Key) + $stringToSign = @( + $Method.ToUpperInvariant() + '' # Content-Encoding + '' # Content-Language + $contentLen # Content-Length ('' if 0) + '' # Content-MD5 + $contentType # Content-Type + '' # Date (using x-ms-date instead) + '' # If-Modified-Since + '' # If-Match + '' # If-None-Match + '' # If-Unmodified-Since + '' # Range + $canonHeaders + $canonResource + ) -join "`n" + + $keyBytes = [Convert]::FromBase64String($script:AzKey) + $hmac = [System.Security.Cryptography.HMACSHA256]::new($keyBytes) + $sig = [Convert]::ToBase64String($hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($stringToSign))) + $authHeader = "SharedKey $($script:AzAccount):$sig" + + $allHeaders = @{ + 'x-ms-date' = $date + 'x-ms-version' = $apiVersion + 'Authorization' = $authHeader + } + foreach ($h in $Headers.Keys) { + if ($h -ne 'Content-Type') { $allHeaders[$h] = $Headers[$h] } + } + + $url = "http://127.0.0.1:$port/$script:AzAccount" + if ($Resource) { $url += "/$Resource" } + $url += $uriQuery + + Write-Host ">> $Method $url" -ForegroundColor Cyan + + $params = @{ + Uri = $url + Method = $Method + Headers = $allHeaders + } + if ($bodyBytes.Length -gt 0) { + $params['Body'] = $bodyBytes + if ($contentType) { $params['ContentType'] = $contentType } + } + + try { + Invoke-WebRequest @params -UseBasicParsing + } + catch { + Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red + # PowerShell 7+ exposes the response body here: + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + Write-Host $_.ErrorDetails.Message -ForegroundColor Yellow + } + # Windows PowerShell 5.1 fallback: + elseif ($_.Exception.Response -and ($_.Exception.Response -is [System.Net.HttpWebResponse])) { + $reader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream()) + Write-Host $reader.ReadToEnd() -ForegroundColor Yellow + } + } +} + +function Invoke-AzuriteTable { + # Table service uses a simpler 'SharedKey' signature than Blob/Queue: + # StringToSign = VERB + \n + Content-MD5 + \n + Content-Type + \n + Date + \n + CanonicalizedResource + param( + [string]$Method = 'GET', + + # Table resource, e.g. 'Tables', 'mytable', or "mytable(PartitionKey='p',RowKey='r')" + [string]$Resource = 'Tables', + + # Query string parameters as a hashtable (only 'comp' participates in signing) + [hashtable]$Query = @{}, + + # Extra request headers (e.g. If-Match) + [hashtable]$Headers = @{}, + + # Request body (JSON string) + [string]$Body = '' + ) + + $port = $script:AzPorts['table'] + $apiVersion = '2025-05-05' + $date = [DateTime]::UtcNow.ToString('R') + + $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($Body) + # Content-Type is only sent (and therefore only signed) when there is a body. + if ($bodyBytes.Length -gt 0) { + $contentType = if ($Headers.ContainsKey('Content-Type')) { $Headers['Content-Type'] } else { 'application/json' } + } + else { + $contentType = '' + } + + # Build query string for the URL (sorted) + $sortedKeys = $Query.Keys | Sort-Object + $uriQuery = '' + if ($Query.Count -gt 0) { + $pairs = foreach ($k in $sortedKeys) { + "$([uri]::EscapeDataString($k))=$([uri]::EscapeDataString([string]$Query[$k]))" + } + $uriQuery = '?' + ($pairs -join '&') + } + + # CanonicalizedResource for Table = /account/resource, plus ?comp=... only if present. + # For the emulator the account name appears twice. + $canonResource = "/$script:AzAccount/$script:AzAccount/$Resource" + if ($Query.ContainsKey('comp')) { $canonResource += "?comp=$($Query['comp'])" } + + $stringToSign = @( + $Method.ToUpperInvariant() + '' # Content-MD5 + $contentType # Content-Type + $date # Date + $canonResource + ) -join "`n" + + $keyBytes = [Convert]::FromBase64String($script:AzKey) + $hmac = [System.Security.Cryptography.HMACSHA256]::new($keyBytes) + $sig = [Convert]::ToBase64String($hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($stringToSign))) + $authHeader = "SharedKey $($script:AzAccount):$sig" + + $allHeaders = @{ + 'x-ms-date' = $date + 'Date' = $date + 'x-ms-version' = $apiVersion + 'Authorization' = $authHeader + 'Accept' = 'application/json;odata=nometadata' + 'DataServiceVersion' = '3.0;NetFx' + } + foreach ($h in $Headers.Keys) { + if ($h -ne 'Content-Type') { $allHeaders[$h] = $Headers[$h] } + } + + $url = "http://127.0.0.1:$port/$script:AzAccount/$Resource$uriQuery" + Write-Host ">> $Method $url" -ForegroundColor Cyan + + $params = @{ + Uri = $url + Method = $Method + Headers = $allHeaders + } + if ($bodyBytes.Length -gt 0) { + $params['Body'] = $bodyBytes + $params['ContentType'] = $contentType + } + + try { + Invoke-WebRequest @params -UseBasicParsing + } + catch { + Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + Write-Host $_.ErrorDetails.Message -ForegroundColor Yellow + } + elseif ($_.Exception.Response -and ($_.Exception.Response -is [System.Net.HttpWebResponse])) { + $reader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream()) + Write-Host $reader.ReadToEnd() -ForegroundColor Yellow + } + } +} + +function Test-AzuriteRest { + # End-to-end smoke test of the raw REST helpers across Blob, Queue and Table. + # Includes explicit round-trip assertions so failures are visible and actionable. + $container = "resttest$(Get-Random -Maximum 9999)" + $queue = "resttest$(Get-Random -Maximum 9999)" + $table = "resttest$(Get-Random -Maximum 9999)" + + Write-Host "`n===== BLOB =====" -ForegroundColor Magenta + Write-Host "[1] Create container" -ForegroundColor Cyan + $blobCreate = Invoke-Azurite -Service blob -Method PUT -Resource $container -Query @{ restype = 'container' } + if (-not $blobCreate) { throw "Blob create container failed." } + + Write-Host "[2] Upload blob" -ForegroundColor Cyan + $blobUpload = Invoke-Azurite -Service blob -Method PUT -Resource "$container/hello.txt" -Body 'Hello Azurite REST' -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = 'text/plain' } + if (-not $blobUpload) { throw "Blob upload failed." } + + Write-Host "[3] Download blob and verify" -ForegroundColor Cyan + $blobGet = Invoke-Azurite -Service blob -Method GET -Resource "$container/hello.txt" + if (-not $blobGet) { throw "Blob download failed." } + $blob = $blobGet.Content + if ($blob -ne 'Hello Azurite REST') { throw "Blob round-trip mismatch. Expected 'Hello Azurite REST', got '$blob'." } + Write-Host "Round-trip OK: blob content matches." -ForegroundColor Green + + Write-Host "[4] Delete container" -ForegroundColor Cyan + $blobDelete = Invoke-Azurite -Service blob -Method DELETE -Resource $container -Query @{ restype = 'container' } + if (-not $blobDelete) { throw "Blob delete container failed." } + + Write-Host "`n===== QUEUE =====" -ForegroundColor Magenta + Write-Host "[1] Create queue" -ForegroundColor Cyan + $queueCreate = Invoke-Azurite -Service queue -Method PUT -Resource $queue + if (-not $queueCreate) { throw "Queue create failed." } + + Write-Host "[2] Put message" -ForegroundColor Cyan + $queuePut = Invoke-Azurite -Service queue -Method POST -Resource "$queue/messages" -Body 'aGVsbG8=' -Headers @{ 'Content-Type' = 'application/xml' } + if (-not $queuePut) { throw "Queue put message failed." } + + Write-Host "[3] Peek messages and verify" -ForegroundColor Cyan + $queuePeek = Invoke-Azurite -Service queue -Method GET -Resource "$queue/messages" -Query @{ peekonly = 'true' } + if (-not $queuePeek) { throw "Queue peek failed." } + [xml]$queueXml = $queuePeek.Content + $msgBase64 = $queueXml.QueueMessagesList.QueueMessage.MessageText + $decodedMsg = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($msgBase64)) + if ($decodedMsg -ne 'hello') { throw "Queue round-trip mismatch. Expected 'hello', got '$decodedMsg'." } + Write-Host "Round-trip OK: queue message matches." -ForegroundColor Green + + Write-Host "[4] Delete queue" -ForegroundColor Cyan + $queueDelete = Invoke-Azurite -Service queue -Method DELETE -Resource $queue + if (-not $queueDelete) { throw "Queue delete failed." } + + Write-Host "`n===== TABLE =====" -ForegroundColor Magenta + Write-Host "[1] Create table" -ForegroundColor Cyan + $tableCreate = Invoke-AzuriteTable -Method POST -Resource 'Tables' -Body "{`"TableName`":`"$table`"}" + if (-not $tableCreate) { throw "Table create failed." } + + Write-Host "[2] Insert entity" -ForegroundColor Cyan + $tableInsert = Invoke-AzuriteTable -Method POST -Resource $table -Body '{"PartitionKey":"p1","RowKey":"r1","Name":"Alice"}' + if (-not $tableInsert) { throw "Table insert entity failed." } + + Write-Host "[3] Query entity and verify" -ForegroundColor Cyan + $tableGet = Invoke-AzuriteTable -Method GET -Resource "$table(PartitionKey='p1',RowKey='r1')" + if (-not $tableGet) { throw "Table query entity failed." } + $entity = $tableGet.Content | ConvertFrom-Json + if ($entity.Name -ne 'Alice') { throw "Table round-trip mismatch. Expected Name='Alice', got '$($entity.Name)'." } + Write-Host "Round-trip OK: table entity matches." -ForegroundColor Green + + Write-Host "[4] Delete table" -ForegroundColor Cyan + $tableDelete = Invoke-AzuriteTable -Method DELETE -Resource "Tables('$table')" -Headers @{ 'If-Match' = '*' } + if (-not $tableDelete) { throw "Table delete failed." } + + Write-Host "`nREST smoke test complete. All checks passed." -ForegroundColor Green +} + +# When RUN directly (.\scripts\azurite-rest.ps1) auto-run the full REST smoke test. +# When DOT-SOURCED (. .\scripts\azurite-rest.ps1) just load the functions. +if ($MyInvocation.InvocationName -ne '.') { + Test-AzuriteRest +} From 418124fb4706e832e1bd9f7bc3284dca322d2580 Mon Sep 17 00:00:00 2001 From: Akanksha Jain Date: Wed, 29 Jul 2026 12:40:19 +0530 Subject: [PATCH 6/7] Maintenance updates and API version enhancements and enhanced error handling for the Blob and Queue services (#2675) * Updated npm package fixes * Updated changelog * Bump morgan and refresh audit changelog note * Bump service API versions to 2026-06-06 and update changelog * Bump axios/esbuild and update constants * Fix #2672: Handle GC startup errors during server initialization Fixes race condition where GC error during startup would attempt to close server while still in 'Starting' state, resulting in 'Cannot close server in status Starting' error. Changes: - Modified BlobServer and QueueServer error handlers to wait for server to reach 'Running' state before attempting close - Added timeout logic (up to 5 seconds) to wait for server startup completion - Gracefully handles case where server cannot transition to Running state - Allows Azurite to recover when encountering errors processing legacy persisted data from previous versions Fixes: #2672 * Add comprehensive test suite for issue #2672 - Added 4 test files with 10 total test cases covering: * Unit tests for GC error handler state machine (3 tests) * Blob server startup error recovery scenarios (3 tests) * Upgrade regression tests (2 tests) * Queue server startup error recovery scenarios (2 tests) - Tests validate: * Fresh start scenarios without persisted data * Graceful error handling during GC initialization in Starting state * Concurrent startup operations * Data persistence across upgrade scenarios * Multi-container and multi-blob scenarios * Recovery from timeout conditions - All 777 tests passing (including 10 new tests) - No regressions in existing tests - Full TypeScript compilation clean --- ChangeLog.md | 15 +- package-lock.json | 464 ++++++------ package.json | 8 +- scripts/azurite-cli.sh | 0 src/azurite.ts | 59 +- src/blob/BlobServer.ts | 85 ++- src/blob/SqlBlobServer.ts | 49 +- src/blob/persistence/LokiBlobMetadataStore.ts | 191 +++-- src/blob/utils/constants.ts | 5 +- src/common/GCCriticalErrorCloseHelper.ts | 70 ++ src/queue/QueueServer.ts | 74 +- src/queue/utils/constants.ts | 5 +- src/table/utils/constants.ts | 5 +- tests/blob/sas.test.ts | 351 ++++++---- tests/blob/startupErrorRecovery.test.ts | 226 ++++++ tests/blob/unit/gcErrorHandler.unit.test.ts | 118 ++++ tests/blob/upgradeRegression.test.ts | 660 ++++++++++++++++++ tests/queue/queueSas.test.ts | 39 +- tests/queue/startupErrorRecovery.test.ts | 166 +++++ tests/table/auth/sas.test.ts | 53 ++ 20 files changed, 2098 insertions(+), 545 deletions(-) mode change 100644 => 100755 scripts/azurite-cli.sh create mode 100644 src/common/GCCriticalErrorCloseHelper.ts create mode 100644 tests/blob/startupErrorRecovery.test.ts create mode 100644 tests/blob/unit/gcErrorHandler.unit.test.ts create mode 100644 tests/blob/upgradeRegression.test.ts create mode 100644 tests/queue/startupErrorRecovery.test.ts diff --git a/ChangeLog.md b/ChangeLog.md index d06a5d2d5..291b2c7a6 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -4,6 +4,20 @@ ## Upcoming Release +General: + +- Applied npm audit fix to updates across multiple dependencies to address security vulnerabilities and maintenance updates. +- Bumped the default Blob, Queue, and Table service API version to `2026-06-06`. +- Added support for service API versions `2026-04-06` and `2026-02-06` for Blob, Queue, and Table endpoints. + +Blob: + +- Fixed issue #2672 startup failures with legacy persisted data by adding backward-compatible restore for persisted `contentMD5` formats. + +Queue: + +- Fixed issue #2672 startup race condition by avoiding GC-triggered `close()` calls while server status is `Starting`. + ## 2026.06 Version 3.36.0 General: @@ -22,7 +36,6 @@ General: - Fix Windows SEA executable build producing a corrupted/bad `.exe` by stripping the Authenticode signature from the copied Node.js binary before injecting the SEA blob via `postject`. - Replace the hardcoded user delegation signing key literal with a deterministic internal signing seed to avoid VS Code extension publish secret-scan blocks while preserving stable user delegation SAS behavior. - Blob: - Remove the default value for the `BlobSequenceNumber` parameter in the Swagger definition. diff --git a/package-lock.json b/package-lock.json index c7fde54b1..7f3f0cbad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,14 @@ "@azure/ms-rest-js": "^2.7.0", "applicationinsights": "^2.9.6", "args": "^5.0.1", - "axios": "^1.17.0", + "axios": "^1.18.1", "etag": "^1.8.1", "express": "^4.16.4", "fs-extra": "^11.1.1", "glob-to-regexp": "^0.4.1", "jsonwebtoken": "^9.0.0", "lokijs": "^1.5.6", - "morgan": "^1.9.1", + "morgan": "^1.11.0", "multistream": "^2.1.1", "mysql2": "^3.10.1", "rimraf": "^3.0.2", @@ -69,7 +69,7 @@ "autorest": "^3.6.0", "cross-env": "^7.0.3", "cross-var": "^1.1.0", - "esbuild": "^0.25.5", + "esbuild": "^0.28.1", "eslint": "^8.35.0", "find-process": "^1.4.4", "husky": "^8.0.1", @@ -334,16 +334,6 @@ "version": "1.14.1", "license": "0BSD" }, - "node_modules/@azure/ms-rest-js/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@azure/ms-rest-js/node_modules/xml2js": { "version": "0.5.0", "license": "MIT", @@ -397,23 +387,27 @@ } }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk": { - "version": "1.0.0-beta.9", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0.tgz", + "integrity": "sha512-Y8rZOIMXQY/GwNRL+uLVuwIn9aEa/KnnggyYUmFxC1MigmRJCNH5NxMmxKSpddXF9SW6Z1ijRd6Pptd2A5OhGw==", "license": "MIT", "dependencies": { "@azure/core-tracing": "^1.2.0", "@azure/logger": "^1.0.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.200.0", + "@opentelemetry/instrumentation": "^0.211.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "tslib": "^2.7.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk/node_modules/@opentelemetry/core": { - "version": "2.0.1", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -527,9 +521,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -544,9 +538,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -561,9 +555,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -578,9 +572,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -595,9 +589,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -612,9 +606,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -629,9 +623,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -646,9 +640,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -663,9 +657,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -680,9 +674,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -697,9 +691,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -714,9 +708,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -731,9 +725,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -748,9 +742,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -765,9 +759,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -782,9 +776,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -799,7 +793,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -814,9 +810,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -831,9 +827,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -848,9 +844,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -865,9 +861,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -882,9 +878,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -899,9 +895,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -916,9 +912,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -933,9 +929,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -950,9 +946,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1160,7 +1156,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.200.0", + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -1190,14 +1188,14 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.200.0", + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.200.0", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "shimmer": "^1.2.1" + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -1227,6 +1225,23 @@ "node": ">=14" } }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "1.30.1", "license": "Apache-2.0", @@ -1250,11 +1265,13 @@ } }, "node_modules/@opentelemetry/sdk-trace-web": { - "version": "2.0.1", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.9.0.tgz", + "integrity": "sha512-LS4XlzOK3e6YYdt84m15AmRR04121rKipmifi4XFZToH1h75f7F2bzHLbB1NMgIEYJ0jSKYm4VsK5mws/5kfTQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/sdk-trace-base": "2.0.1" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -1264,7 +1281,9 @@ } }, "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/core": { - "version": "2.0.1", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -1277,10 +1296,12 @@ } }, "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/resources": { - "version": "2.0.1", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.0.1", + "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -1291,11 +1312,45 @@ } }, "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.0.1", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -1540,10 +1595,6 @@ "@types/send": "*" } }, - "node_modules/@types/shimmer": { - "version": "1.2.0", - "license": "MIT" - }, "node_modules/@types/stoppable": { "version": "1.1.3", "dev": true, @@ -1808,6 +1859,8 @@ }, "node_modules/acorn-import-attributes": { "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "license": "MIT", "peerDependencies": { "acorn": "^8" @@ -2057,9 +2110,9 @@ } }, "node_modules/axios": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", - "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -3297,7 +3350,9 @@ "license": "ISC" }, "node_modules/cjs-module-lexer": { - "version": "1.4.3", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "license": "MIT" }, "node_modules/cli-cursor": { @@ -4110,9 +4165,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4123,32 +4178,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -5367,13 +5422,15 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.14.2", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", "license": "Apache-2.0", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" } }, "node_modules/imurmurhash": { @@ -5602,8 +5659,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6508,6 +6577,8 @@ }, "node_modules/module-details-from-path": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, "node_modules/moment": { @@ -6528,17 +6599,23 @@ } }, "node_modules/morgan": { - "version": "1.10.1", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", - "on-finished": "~2.3.0", + "on-finished": "~2.4.1", "on-headers": "~1.1.0" }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/morgan/node_modules/debug": { @@ -6552,16 +6629,6 @@ "version": "2.0.0", "license": "MIT" }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/mri": { "version": "1.1.4", "license": "MIT", @@ -6977,10 +7044,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "license": "MIT" - }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -7424,33 +7487,16 @@ } }, "node_modules/require-in-the-middle": { - "version": "7.5.2", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", "license": "MIT", "dependencies": { "debug": "^4.3.5", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "module-details-from-path": "^1.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/resolve-from": { @@ -7461,19 +7507,6 @@ "node": ">=4" } }, - "node_modules/resolve/node_modules/is-core-module": { - "version": "2.16.1", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/restore-cursor": { "version": "5.1.0", "dev": true, @@ -7739,13 +7772,6 @@ "node": ">= 10.0.0" } }, - "node_modules/sequelize/node_modules/uuid": { - "version": "8.3.2", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "dev": true, @@ -8081,16 +8107,6 @@ "node": ">=4" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -8522,6 +8538,16 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "dev": true, diff --git a/package.json b/package.json index 29497be40..5b765830e 100644 --- a/package.json +++ b/package.json @@ -23,14 +23,14 @@ "@azure/ms-rest-js": "^2.7.0", "applicationinsights": "^2.9.6", "args": "^5.0.1", - "axios": "^1.17.0", + "axios": "^1.18.1", "etag": "^1.8.1", "express": "^4.16.4", "fs-extra": "^11.1.1", "glob-to-regexp": "^0.4.1", "jsonwebtoken": "^9.0.0", "lokijs": "^1.5.6", - "morgan": "^1.9.1", + "morgan": "^1.11.0", "multistream": "^2.1.1", "mysql2": "^3.10.1", "rimraf": "^3.0.2", @@ -74,7 +74,7 @@ "autorest": "^3.6.0", "cross-env": "^7.0.3", "cross-var": "^1.1.0", - "esbuild": "^0.25.5", + "esbuild": "^0.28.1", "eslint": "^8.35.0", "find-process": "^1.4.4", "husky": "^8.0.1", @@ -354,4 +354,4 @@ "url": "https://github.com/azure/azurite/issues" }, "homepage": "https://github.com/azure/azurite#readme" -} \ No newline at end of file +} diff --git a/scripts/azurite-cli.sh b/scripts/azurite-cli.sh old mode 100644 new mode 100755 diff --git a/src/azurite.ts b/src/azurite.ts index e856c3e25..297b86083 100644 --- a/src/azurite.ts +++ b/src/azurite.ts @@ -25,6 +25,7 @@ import TableServer from "./table/TableServer"; import { DEFAULT_TABLE_LOKI_DB_PATH } from "./table/utils/constants"; import { setExtentMemoryLimit } from "./common/ConfigurationBase"; import { AzuriteTelemetryClient } from "./common/Telemetry"; +import { ServerStatus } from "./common/ServerBase"; // tslint:disable:no-console @@ -42,30 +43,50 @@ function shutdown( AzuriteTelemetryClient.TraceStopEvent(); - console.log(blobBeforeCloseMessage); - blobServer.close().then(() => { - console.log(blobAfterCloseMessage); - }); - - console.log(queueBeforeCloseMessage); - queueServer.close().then(() => { - console.log(queueAfterCloseMessage); - }); - - console.log(tableBeforeCloseMessage); - tableServer.close().then(() => { - console.log(tableAfterCloseMessage); - }); + const closeWithDiagnostics = ( + beforeMessage: string, + afterMessage: string, + server: { getStatus(): ServerStatus; close(): Promise } + ) => { + const statusBeforeClose = server.getStatus(); + console.log(beforeMessage); + server + .close() + .then(() => { + console.log(afterMessage); + }) + .catch((err) => { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error( + `Shutdown close failed in status ${statusBeforeClose}: ${errorMsg}` + ); + }); + }; + + closeWithDiagnostics( + blobBeforeCloseMessage, + blobAfterCloseMessage, + blobServer + ); + closeWithDiagnostics( + queueBeforeCloseMessage, + queueAfterCloseMessage, + queueServer + ); + closeWithDiagnostics( + tableBeforeCloseMessage, + tableAfterCloseMessage, + tableServer + ); } /** * Entry for Azurite services. */ async function main() { - // Initialize and validate environment values from command line parameters const env = new Environment(); - + const location = await env.location(); await ensureDir(location); await access(location); @@ -105,7 +126,7 @@ async function main() { env.pwd(), env.oauth(), env.disableProductStyleUrl(), - env.inMemoryPersistence(), + env.inMemoryPersistence() ); const tableConfig = new TableConfiguration( @@ -124,7 +145,7 @@ async function main() { env.pwd(), env.oauth(), env.disableProductStyleUrl(), - env.inMemoryPersistence(), + env.inMemoryPersistence() ); // We use logger singleton as global debugger logger to track detailed outputs cross layers @@ -167,7 +188,7 @@ async function main() { console.log( `Azurite Table service is successfully listening at ${tableServer.getHttpServerAddress()}` ); - + AzuriteTelemetryClient.init(location, !env.disableTelemetry(), env); await AzuriteTelemetryClient.TraceStartEvent(); diff --git a/src/blob/BlobServer.ts b/src/blob/BlobServer.ts index 2f0e10dc3..fcf5952eb 100644 --- a/src/blob/BlobServer.ts +++ b/src/blob/BlobServer.ts @@ -9,10 +9,13 @@ import IGCManager from "../common/IGCManager"; import IRequestListenerFactory from "../common/IRequestListenerFactory"; import logger from "../common/Logger"; import FSExtentStore from "../common/persistence/FSExtentStore"; -import MemoryExtentStore, { SharedChunkStore } from "../common/persistence/MemoryExtentStore"; +import MemoryExtentStore, { + SharedChunkStore +} from "../common/persistence/MemoryExtentStore"; import IExtentMetadataStore from "../common/persistence/IExtentMetadataStore"; import IExtentStore from "../common/persistence/IExtentStore"; import LokiExtentMetadataStore from "../common/persistence/LokiExtentMetadataStore"; +import { handleGCCriticalErrorClose } from "../common/GCCriticalErrorCloseHelper"; import ServerBase, { ServerStatus } from "../common/ServerBase"; import BlobConfiguration from "./BlobConfiguration"; import BlobRequestListenerFactory from "./BlobRequestListenerFactory"; @@ -79,39 +82,43 @@ export default class BlobServer extends ServerBase implements ICleaner { configuration.isMemoryPersistence ); - const extentMetadataStore: IExtentMetadataStore = new LokiExtentMetadataStore( - configuration.extentDBPath, - configuration.isMemoryPersistence - ); - - const extentStore: IExtentStore = configuration.isMemoryPersistence ? new MemoryExtentStore( - "blob", - configuration.memoryStore ?? SharedChunkStore, - extentMetadataStore, - logger, - (sc, er, em, ri) => new StorageError(sc, er, em, ri) - ) : new FSExtentStore( - extentMetadataStore, - configuration.persistencePathArray, - logger - ); + const extentMetadataStore: IExtentMetadataStore = + new LokiExtentMetadataStore( + configuration.extentDBPath, + configuration.isMemoryPersistence + ); + + const extentStore: IExtentStore = configuration.isMemoryPersistence + ? new MemoryExtentStore( + "blob", + configuration.memoryStore ?? SharedChunkStore, + extentMetadataStore, + logger, + (sc, er, em, ri) => new StorageError(sc, er, em, ri) + ) + : new FSExtentStore( + extentMetadataStore, + configuration.persistencePathArray, + logger + ); const accountDataStore: IAccountDataStore = new AccountDataStore(logger); // We can also change the HTTP framework here by // creating a new XXXListenerFactory implementing IRequestListenerFactory interface // and replace the default Express based request listener - const requestListenerFactory: IRequestListenerFactory = new BlobRequestListenerFactory( - metadataStore, - extentStore, - accountDataStore, - configuration.enableAccessLog, // Access log includes every handled HTTP request - configuration.accessLogWriteStream, - configuration.loose, - configuration.skipApiVersionCheck, - configuration.getOAuthLevel(), - configuration.disableProductStyleUrl - ); + const requestListenerFactory: IRequestListenerFactory = + new BlobRequestListenerFactory( + metadataStore, + extentStore, + accountDataStore, + configuration.enableAccessLog, // Access log includes every handled HTTP request + configuration.accessLogWriteStream, + configuration.loose, + configuration.skipApiVersionCheck, + configuration.getOAuthLevel(), + configuration.disableProductStyleUrl + ); super(host, port, httpServer, requestListenerFactory, configuration); @@ -121,14 +128,26 @@ export default class BlobServer extends ServerBase implements ICleaner { metadataStore, extentMetadataStore, extentStore, - () => { + (err: Error) => { // tslint:disable-next-line:no-console console.log(BEFORE_CLOSE_MESSAGE_GC_ERROR); logger.info(BEFORE_CLOSE_MESSAGE_GC_ERROR); - this.close().then(() => { - // tslint:disable-next-line:no-console - console.log(AFTER_CLOSE_MESSAGE); - logger.info(AFTER_CLOSE_MESSAGE); + logger.error( + `Blob GC Manager critical error: ${err.message}\nStack: ${err.stack}` + ); + logger.warn( + "This may be due to: " + + "1) Persisted database format changes, " + + "2) File system permission issues, " + + "3) Corrupted metadata files. " + + "See error details above." + ); + + handleGCCriticalErrorClose({ + serviceName: "Blob", + getStatus: () => this.status, + close: () => this.close(), + logger }); }, logger diff --git a/src/blob/SqlBlobServer.ts b/src/blob/SqlBlobServer.ts index c0e07e6d3..020a9b6a5 100644 --- a/src/blob/SqlBlobServer.ts +++ b/src/blob/SqlBlobServer.ts @@ -6,6 +6,7 @@ import { CertOptions } from "../common/ConfigurationBase"; import IAccountDataStore from "../common/IAccountDataStore"; import IGCManager from "../common/IGCManager"; import IRequestListenerFactory from "../common/IRequestListenerFactory"; +import { handleGCCriticalErrorClose } from "../common/GCCriticalErrorCloseHelper"; import logger from "../common/Logger"; import FSExtentStore from "../common/persistence/FSExtentStore"; import IExtentMetadataStore from "../common/persistence/IExtentMetadataStore"; @@ -69,12 +70,13 @@ export default class SqlBlobServer extends ServerBase { configuration.sequelizeOptions ); - const extentMetadataStore: IExtentMetadataStore = new SqlExtentMetadataStore( - // Currently, extent metadata and blob metadata share same database - // But they can use separate databases per future requirements - configuration.sqlURL, - configuration.sequelizeOptions - ); + const extentMetadataStore: IExtentMetadataStore = + new SqlExtentMetadataStore( + // Currently, extent metadata and blob metadata share same database + // But they can use separate databases per future requirements + configuration.sqlURL, + configuration.sequelizeOptions + ); const extentStore: IExtentStore = new FSExtentStore( extentMetadataStore, @@ -87,17 +89,18 @@ export default class SqlBlobServer extends ServerBase { // We can also change the HTTP framework here by // creating a new XXXListenerFactory implementing IRequestListenerFactory interface // and replace the default Express based request listener - const requestListenerFactory: IRequestListenerFactory = new BlobRequestListenerFactory( - metadataStore, - extentStore, - accountDataStore, - configuration.enableAccessLog, // Access log includes every handled HTTP request - configuration.accessLogWriteStream, - configuration.loose, - configuration.skipApiVersionCheck, - configuration.getOAuthLevel(), - configuration.disableProductStyleUrl - ); + const requestListenerFactory: IRequestListenerFactory = + new BlobRequestListenerFactory( + metadataStore, + extentStore, + accountDataStore, + configuration.enableAccessLog, // Access log includes every handled HTTP request + configuration.accessLogWriteStream, + configuration.loose, + configuration.skipApiVersionCheck, + configuration.getOAuthLevel(), + configuration.disableProductStyleUrl + ); super(host, port, httpServer, requestListenerFactory, configuration); @@ -107,16 +110,16 @@ export default class SqlBlobServer extends ServerBase { metadataStore, extentMetadataStore, extentStore, - error => { + (error) => { // tslint:disable-next-line:no-console console.log(BEFORE_CLOSE_MESSAGE_GC_ERROR, error); logger.info(BEFORE_CLOSE_MESSAGE_GC_ERROR + JSON.stringify(error)); - // TODO: Bring this back when GC based on SQL implemented - this.close().then(() => { - // tslint:disable-next-line:no-console - console.log(AFTER_CLOSE_MESSAGE); - logger.info(AFTER_CLOSE_MESSAGE); + handleGCCriticalErrorClose({ + serviceName: "Blob", + getStatus: () => this.status, + close: () => this.close(), + logger }); }, logger diff --git a/src/blob/persistence/LokiBlobMetadataStore.ts b/src/blob/persistence/LokiBlobMetadataStore.ts index 7efc4d7c6..d0e3f62d6 100644 --- a/src/blob/persistence/LokiBlobMetadataStore.ts +++ b/src/blob/persistence/LokiBlobMetadataStore.ts @@ -65,7 +65,11 @@ import IBlobMetadataStore, { import PageWithDelimiter from "./PageWithDelimiter"; import FilterBlobPage from "./FilterBlobPage"; import { generateQueryBlobWithTagsWhereFunction } from "./QueryInterpreter/QueryInterpreter"; -import { getBlobTagsCount, getTagsFromString, toBlobTags } from "../utils/utils"; +import { + getBlobTagsCount, + getTagsFromString, + toBlobTags +} from "../utils/utils"; /** * This is a metadata source implementation for blob based on loki DB. @@ -95,7 +99,8 @@ import { getBlobTagsCount, getTagsFromString, toBlobTags } from "../utils/utils" * @class LokiBlobMetadataStore */ export default class LokiBlobMetadataStore - implements IBlobMetadataStore, IGCExtentProvider { + implements IBlobMetadataStore, IGCExtentProvider +{ private readonly db: Loki; private initialized: boolean = false; @@ -108,14 +113,22 @@ export default class LokiBlobMetadataStore private readonly pageBlobRangesManager = new PageBlobRangesManager(); - public constructor(public readonly lokiDBPath: string, inMemory: boolean) { - this.db = new Loki(lokiDBPath, inMemory ? { - persistenceMethod: "memory" - } : { - persistenceMethod: "fs", - autosave: true, - autosaveInterval: 5000 - }); + public constructor( + public readonly lokiDBPath: string, + inMemory: boolean + ) { + this.db = new Loki( + lokiDBPath, + inMemory + ? { + persistenceMethod: "memory" + } + : { + persistenceMethod: "fs", + autosave: true, + autosaveInterval: 5000 + } + ); } public isInitialized(): boolean { @@ -329,9 +342,9 @@ export default class LokiBlobMetadataStore prefix === "" ? { name: { $gt: marker }, accountName: account } : { - name: { $regex: `^${this.escapeRegex(prefix)}`, $gt: marker }, - accountName: account - }; + name: { $regex: `^${this.escapeRegex(prefix)}`, $gt: marker }, + accountName: account + }; // Workaround for loki which will ignore $gt when providing $regex const query2 = { name: { $gt: marker } }; @@ -753,10 +766,10 @@ export default class LokiBlobMetadataStore const leaseTimeSeconds: number = doc.properties.leaseState === Models.LeaseStateType.Breaking && - doc.leaseBreakTime + doc.leaseBreakTime ? Math.round( - (doc.leaseBreakTime.getTime() - context.startTime!.getTime()) / 1000 - ) + (doc.leaseBreakTime.getTime() - context.startTime!.getTime()) / 1000 + ) : 0; coll.update(doc); @@ -830,7 +843,7 @@ export default class LokiBlobMetadataStore container?: string, where?: string, maxResults: number = DEFAULT_LIST_BLOBS_MAX_RESULTS, - marker: string = "", + marker: string = "" ): Promise<[FilterBlobModel[], string | undefined]> { const query: any = {}; if (account !== undefined) { @@ -838,14 +851,13 @@ export default class LokiBlobMetadataStore } if (container !== undefined) { query.containerName = container; - await this.checkContainerExist( - context, - account, - container - ); + await this.checkContainerExist(context, account, container); } - const filterFunction = generateQueryBlobWithTagsWhereFunction(context, where!); + const filterFunction = generateQueryBlobWithTagsWhereFunction( + context, + where! + ); const coll = this.db.getCollection(this.BLOBS_COLLECTION); const page = new FilterBlobPage(maxResults); @@ -857,7 +869,7 @@ export default class LokiBlobMetadataStore return obj.name > marker!; }) .where((obj) => { - return obj.snapshot === undefined || obj.snapshot === ''; + return obj.snapshot === undefined || obj.snapshot === ""; }) .sort((obj1, obj2) => { if (obj1.name === obj2.name) return 0; @@ -868,22 +880,24 @@ export default class LokiBlobMetadataStore .limit(maxResults) .data(); - return doc.map((item) => { - let blobItem: FilterBlobModel; - blobItem = { - name: item.name, - containerName: item.containerName, - tags: item.blobTags - }; - return blobItem; - }).filter((blobItem) => { - const tagsMeetConditions = filterFunction(blobItem); - if (tagsMeetConditions.length !== 0) { - blobItem.tags = { blobTagSet: toBlobTags(tagsMeetConditions) }; - return true; - } - return false; - }); + return doc + .map((item) => { + let blobItem: FilterBlobModel; + blobItem = { + name: item.name, + containerName: item.containerName, + tags: item.blobTags + }; + return blobItem; + }) + .filter((blobItem) => { + const tagsMeetConditions = filterFunction(blobItem); + if (tagsMeetConditions.length !== 0) { + blobItem.tags = { blobTagSet: toBlobTags(tagsMeetConditions) }; + return true; + } + return false; + }); }; const nameItem = (item: FilterBlobModel) => { @@ -892,10 +906,7 @@ export default class LokiBlobMetadataStore const [blobItems, nextMarker] = await page.fill(readPage, nameItem); - return [ - blobItems, - nextMarker - ]; + return [blobItems, nextMarker]; } public async listBlobs( @@ -925,7 +936,11 @@ export default class LokiBlobMetadataStore } const coll = this.db.getCollection(this.BLOBS_COLLECTION); - const page = new PageWithDelimiter(maxResults, delimiter, prefix); + const page = new PageWithDelimiter( + maxResults, + delimiter, + prefix + ); const readPage = async (offset: number): Promise => { return await coll .chain() @@ -953,7 +968,10 @@ export default class LokiBlobMetadataStore return item.name; }; - const [blobItems, blobPrefixes, nextMarker] = await page.fill(readPage, nameItem); + const [blobItems, blobPrefixes, nextMarker] = await page.fill( + readPage, + nameItem + ); return [ blobItems.map((doc) => { @@ -1766,10 +1784,10 @@ export default class LokiBlobMetadataStore const leaseTimeSeconds: number = doc.properties.leaseState === Models.LeaseStateType.Breaking && - doc.leaseBreakTime + doc.leaseBreakTime ? Math.round( - (doc.leaseBreakTime.getTime() - context.startTime!.getTime()) / 1000 - ) + (doc.leaseBreakTime.getTime() - context.startTime!.getTime()) / 1000 + ) : 0; coll.update(doc); @@ -1934,8 +1952,10 @@ export default class LokiBlobMetadataStore throw StorageErrorFactory.getBlobNotFound(context.contextId!); } - if (sourceBlob.properties.accessTier === Models.AccessTier.Archive - && (tier === undefined || source.account !== destination.account)) { + if ( + sourceBlob.properties.accessTier === Models.AccessTier.Archive && + (tier === undefined || source.account !== destination.account) + ) { throw StorageErrorFactory.getBlobArchived(context.contextId!); } @@ -1981,7 +2001,9 @@ export default class LokiBlobMetadataStore remainingRetentionDays: undefined, archiveStatus: undefined, accessTierChangeTime: undefined, - ...(sourceBlob.properties.blobType === Models.BlobType.AppendBlob && { isSealed: options.sealBlob }), + ...(sourceBlob.properties.blobType === Models.BlobType.AppendBlob && { + isSealed: options.sealBlob + }) }, metadata: metadata === undefined || Object.keys(metadata).length === 0 @@ -2000,7 +2022,10 @@ export default class LokiBlobMetadataStore destBlob !== undefined ? destBlob.leaseBreakTime : undefined, committedBlocksInOrder: sourceBlob.committedBlocksInOrder, persistency: sourceBlob.persistency, - blobTags: options.blobTagsString === undefined ? undefined : getTagsFromString(options.blobTagsString, context.contextId!) + blobTags: + options.blobTagsString === undefined + ? undefined + : getTagsFromString(options.blobTagsString, context.contextId!) }; if ( @@ -2076,7 +2101,7 @@ export default class LokiBlobMetadataStore ifUnmodifiedSince: options.sourceModifiedAccessConditions.sourceIfUnmodifiedSince, ifMatch: options.sourceModifiedAccessConditions.sourceIfMatch, - ifNoneMatch: options.sourceModifiedAccessConditions.sourceIfNoneMatch, + ifNoneMatch: options.sourceModifiedAccessConditions.sourceIfNoneMatch // Storage service will ignore x-ms-source-if-tags header for copyFromUrl }, sourceBlob @@ -2188,9 +2213,12 @@ export default class LokiBlobMetadataStore destBlob !== undefined ? destBlob.leaseBreakTime : undefined, committedBlocksInOrder: sourceBlob.committedBlocksInOrder, persistency: sourceBlob.persistency, - blobTags: options.copySourceTags === Models.BlobCopySourceTags.COPY ? - sourceBlob.blobTags - : options.blobTagsString === undefined ? undefined : getTagsFromString(options.blobTagsString, context.contextId!) + blobTags: + options.copySourceTags === Models.BlobCopySourceTags.COPY + ? sourceBlob.blobTags + : options.blobTagsString === undefined + ? undefined + : getTagsFromString(options.blobTagsString, context.contextId!) }; if ( @@ -2281,7 +2309,9 @@ export default class LokiBlobMetadataStore // Archive -> Coo/Hot will return 202 if ( doc.properties.accessTier === Models.AccessTier.Archive && - (tier === Models.AccessTier.Cool || tier === Models.AccessTier.Hot || tier === Models.AccessTier.Cold) + (tier === Models.AccessTier.Cool || + tier === Models.AccessTier.Hot || + tier === Models.AccessTier.Cold) ) { responseCode = 202; } @@ -2366,7 +2396,7 @@ export default class LokiBlobMetadataStore const existBlockDoc = coll.findOne({ blobName: block.blobName, accountName: block.accountName, - containerName: block.containerName, + containerName: block.containerName }); if (existBlockDoc) { if ( @@ -2418,10 +2448,7 @@ export default class LokiBlobMetadataStore } const lease = new BlobLeaseAdapter(doc); - new BlobWriteLeaseValidator(leaseAccessConditions).validate( - lease, - context - ); + new BlobWriteLeaseValidator(leaseAccessConditions).validate(lease, context); if (doc.properties.isSealed) { throw StorageErrorFactory.getBlobSealed(context.contextId); @@ -3109,7 +3136,7 @@ export default class LokiBlobMetadataStore * @memberof LokiBlobMetadataStore */ private restoreUint8Array(obj: any): Uint8Array | undefined { - if (typeof obj !== "object") { + if (obj === null || typeof obj !== "object") { return undefined; } @@ -3117,6 +3144,17 @@ export default class LokiBlobMetadataStore return new Uint8Array(obj); } + // Backward compatibility: persisted Buffer JSON shape from previous versions + // e.g. { type: "Buffer", data: [1,2,3] } + if (obj.type === "Buffer" && Array.isArray(obj.data)) { + return new Uint8Array(obj.data); + } + + // Backward compatibility: plain array-like persisted by serializers + if (Array.isArray(obj)) { + return new Uint8Array(obj); + } + const length = Object.keys(obj).length; const arr = Buffer.allocUnsafe(length); @@ -3517,22 +3555,22 @@ export default class LokiBlobMetadataStore } /** - * Seal blob. - * - * @param {Context} context - * @param {string} account - * @param {string} container - * @param {string} blob - * @returns {Promise} - * @memberof IBlobMetadataStore - */ + * Seal blob. + * + * @param {Context} context + * @param {string} account + * @param {string} container + * @param {string} blob + * @returns {Promise} + * @memberof IBlobMetadataStore + */ public async sealBlob( context: Context, account: string, container: string, blob: string, snapshot: string | undefined, - options: Models.AppendBlobSealOptionalParams, + options: Models.AppendBlobSealOptionalParams ): Promise { const coll = this.db.getCollection(this.BLOBS_COLLECTION); const doc = await this.getBlob(context, account, container, blob); @@ -3548,7 +3586,10 @@ export default class LokiBlobMetadataStore } const lease = new BlobLeaseAdapter(doc); - new BlobWriteLeaseValidator(options.leaseAccessConditions).validate(lease, context); + new BlobWriteLeaseValidator(options.leaseAccessConditions).validate( + lease, + context + ); new BlobWriteLeaseSyncer(doc).sync(lease); doc.properties.isSealed = true; diff --git a/src/blob/utils/constants.ts b/src/blob/utils/constants.ts index 40319eea4..5a93a7dcd 100644 --- a/src/blob/utils/constants.ts +++ b/src/blob/utils/constants.ts @@ -2,7 +2,7 @@ import { StoreDestinationArray } from "../../common/persistence/IExtentStore"; import * as Models from "../generated/artifacts/models"; export const VERSION = "3.36.0"; -export const BLOB_API_VERSION = "2025-11-05"; +export const BLOB_API_VERSION = "2026-06-06"; export const DEFAULT_BLOB_SERVER_HOST_NAME = "127.0.0.1"; // Change to 0.0.0.0 when needs external access export const DEFAULT_LIST_BLOBS_MAX_RESULTS = 5000; export const DEFAULT_LIST_CONTAINERS_MAX_RESULTS = 5000; @@ -98,6 +98,9 @@ export const DEFAULT_BLOB_PERSISTENCE_ARRAY: StoreDestinationArray = [ ]; export const ValidAPIVersions = [ + "2026-06-06", + "2026-04-06", + "2026-02-06", "2025-11-05", "2025-07-05", "2025-05-05", diff --git a/src/common/GCCriticalErrorCloseHelper.ts b/src/common/GCCriticalErrorCloseHelper.ts new file mode 100644 index 000000000..6b7112d32 --- /dev/null +++ b/src/common/GCCriticalErrorCloseHelper.ts @@ -0,0 +1,70 @@ +import ILogger from "./ILogger"; +import { ServerStatus } from "./ServerBase"; + +export interface GCCriticalErrorCloseHelperOptions { + readonly serviceName: string; + readonly getStatus: () => ServerStatus; + readonly close: () => Promise; + readonly logger: ILogger; + readonly waitIntervalMs?: number; + readonly maxAttempts?: number; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) as unknown as { + unref?: () => void; + }; + if (typeof timer.unref === "function") { + timer.unref(); + } + }); +} + +/** + * Handles the startup/close race when a critical GC error occurs while a server is still Starting. + */ +export async function handleGCCriticalErrorClose( + options: GCCriticalErrorCloseHelperOptions +): Promise { + const waitIntervalMs = options.waitIntervalMs ?? 100; + const maxAttempts = options.maxAttempts ?? 50; + + try { + let attempts = 0; + let startupTimeoutLogged = false; + while (options.getStatus() === ServerStatus.Starting) { + if (!startupTimeoutLogged && attempts >= maxAttempts) { + startupTimeoutLogged = true; + options.logger.error( + `GC error occurred during server startup. Server did not transition to Running state ` + + `within ~${waitIntervalMs * maxAttempts}ms. Current status: ${ServerStatus.Starting}. ` + + `Will keep waiting and close if server reaches Running later.` + ); + } + + await delay(waitIntervalMs); + attempts++; + } + + const status = options.getStatus(); + if (status === ServerStatus.Running) { + options.logger.info( + `Shutting down ${options.serviceName} server due to GC critical error` + ); + await options.close(); + return; + } + + options.logger.warn( + `${options.serviceName} server status is ${status} (expected Running). ` + + `Server state may be inconsistent. Check logs for root cause.` + ); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + options.logger.error( + `${options.serviceName} server error during GC error handling: ${errorMsg}. ` + + `Server status: ${options.getStatus()}` + ); + } +} diff --git a/src/queue/QueueServer.ts b/src/queue/QueueServer.ts index cc61bd200..802007082 100644 --- a/src/queue/QueueServer.ts +++ b/src/queue/QueueServer.ts @@ -8,10 +8,13 @@ import IGCManager from "../common/IGCManager"; import IRequestListenerFactory from "../common/IRequestListenerFactory"; import logger from "../common/Logger"; import FSExtentStore from "../common/persistence/FSExtentStore"; -import MemoryExtentStore, { SharedChunkStore } from "../common/persistence/MemoryExtentStore"; +import MemoryExtentStore, { + SharedChunkStore +} from "../common/persistence/MemoryExtentStore"; import IExtentMetadataStore from "../common/persistence/IExtentMetadataStore"; import IExtentStore from "../common/persistence/IExtentStore"; import LokiExtentMetadataStore from "../common/persistence/LokiExtentMetadataStore"; +import { handleGCCriticalErrorClose } from "../common/GCCriticalErrorCloseHelper"; import ServerBase, { ServerStatus } from "../common/ServerBase"; import QueueGCManager from "./gc/QueueGCManager"; import IQueueMetadataStore from "./persistence/IQueueMetadataStore"; @@ -71,7 +74,7 @@ export default class QueueServer extends ServerBase { } if (configuration.keepAliveTimeout > 0) { - httpServer.keepAliveTimeout = configuration.keepAliveTimeout * 1000 + httpServer.keepAliveTimeout = configuration.keepAliveTimeout * 1000; } // We can change the persistency layer implementation by @@ -87,33 +90,36 @@ export default class QueueServer extends ServerBase { configuration.isMemoryPersistence ); - const extentStore: IExtentStore = configuration.isMemoryPersistence ? new MemoryExtentStore( - "queue", - configuration.memoryStore ?? SharedChunkStore, - extentMetadataStore, - logger, - (sc, er, em, ri) => new StorageError(sc, er, em, ri) - ) : new FSExtentStore( - extentMetadataStore, - configuration.persistencePathArray, - logger - ); + const extentStore: IExtentStore = configuration.isMemoryPersistence + ? new MemoryExtentStore( + "queue", + configuration.memoryStore ?? SharedChunkStore, + extentMetadataStore, + logger, + (sc, er, em, ri) => new StorageError(sc, er, em, ri) + ) + : new FSExtentStore( + extentMetadataStore, + configuration.persistencePathArray, + logger + ); const accountDataStore: IAccountDataStore = new AccountDataStore(logger); // We can also change the HTTP framework here by // creating a new XXXListenerFactory implementing IRequestListenerFactory interface // and replace the default Express based request listener - const requestListenerFactory: IRequestListenerFactory = new QueueRequestListenerFactory( - metadataStore, - extentStore, - accountDataStore, - configuration.enableAccessLog, // Access log includes every handled HTTP request - configuration.accessLogWriteStream, - configuration.skipApiVersionCheck, - configuration.getOAuthLevel(), - configuration.disableProductStyleUrl - ); + const requestListenerFactory: IRequestListenerFactory = + new QueueRequestListenerFactory( + metadataStore, + extentStore, + accountDataStore, + configuration.enableAccessLog, // Access log includes every handled HTTP request + configuration.accessLogWriteStream, + configuration.skipApiVersionCheck, + configuration.getOAuthLevel(), + configuration.disableProductStyleUrl + ); super(host, port, httpServer, requestListenerFactory, configuration); @@ -121,14 +127,26 @@ export default class QueueServer extends ServerBase { metadataStore, extentMetadataStore, extentStore, - () => { + (err: Error) => { // tslint:disable-next-line:no-console console.log(BEFORE_CLOSE_MESSAGE_GC_ERROR); logger.info(BEFORE_CLOSE_MESSAGE_GC_ERROR); - this.close().then(() => { - // tslint:disable-next-line:no-console - console.log(AFTER_CLOSE_MESSAGE); - logger.info(AFTER_CLOSE_MESSAGE); + logger.error( + `Queue GC Manager critical error: ${err.message}\nStack: ${err.stack}` + ); + logger.warn( + "This may be due to: " + + "1) Persisted database format changes, " + + "2) File system permission issues, " + + "3) Corrupted metadata files. " + + "See error details above." + ); + + handleGCCriticalErrorClose({ + serviceName: "Queue", + getStatus: () => this.status, + close: () => this.close(), + logger }); }, logger diff --git a/src/queue/utils/constants.ts b/src/queue/utils/constants.ts index 950bd2a70..53033a172 100644 --- a/src/queue/utils/constants.ts +++ b/src/queue/utils/constants.ts @@ -1,7 +1,7 @@ import { StoreDestinationArray } from "../../common/persistence/IExtentStore"; export const VERSION = "3.36.0"; -export const QUEUE_API_VERSION = "2025-11-05"; +export const QUEUE_API_VERSION = "2026-06-06"; export const DEFAULT_QUEUE_SERVER_HOST_NAME = "127.0.0.1"; // Change to 0.0.0.0 when needs external access export const DEFAULT_QUEUE_LISTENING_PORT = 10001; export const IS_PRODUCTION = process.env.NODE_ENV === "production"; @@ -91,6 +91,9 @@ export const DEFAULT_QUEUE_PERSISTENCE_ARRAY: StoreDestinationArray = [ ]; export const ValidAPIVersions = [ + "2026-06-06", + "2026-04-06", + "2026-02-06", "2025-11-05", "2025-07-05", "2025-05-05", diff --git a/src/table/utils/constants.ts b/src/table/utils/constants.ts index a6e6174ae..4ecdfb303 100644 --- a/src/table/utils/constants.ts +++ b/src/table/utils/constants.ts @@ -18,7 +18,7 @@ export enum TABLE_STATUSCODE { } export const DEFAULT_TABLE_CONTEXT_PATH = "azurite_table_context"; -export const TABLE_API_VERSION = "2025-11-05"; +export const TABLE_API_VERSION = "2026-06-06"; export const VERSION = "3.36.0"; // Max Body size is 4 MB export const BODY_SIZE_MAX = 1024 * 1024 * 4; @@ -74,6 +74,9 @@ export const DEFAULT_TABLE_PERSISTENCE_ARRAY: StoreDestinationArray = [ export const QUERY_RESULT_MAX_NUM = 1000; export const ValidAPIVersions = [ + "2026-06-06", + "2026-04-06", + "2026-02-06", "2025-11-05", "2025-07-05", "2025-05-05", diff --git a/tests/blob/sas.test.ts b/tests/blob/sas.test.ts index 3385324d3..d3294a12c 100644 --- a/tests/blob/sas.test.ts +++ b/tests/blob/sas.test.ts @@ -40,9 +40,8 @@ configLogger(false); describe("Shared Access Signature (SAS) authentication", () => { // Setup two accounts for validating cross-account copy operations - process.env[ - "AZURITE_ACCOUNTS" - ] = `${EMULATOR_ACCOUNT_NAME}:${EMULATOR_ACCOUNT_KEY_STR}:${EMULATOR_ACCOUNT_KEY2_STR};${EMULATOR_ACCOUNT2_NAME}:${EMULATOR_ACCOUNT2_KEY_STR}`; + process.env["AZURITE_ACCOUNTS"] = + `${EMULATOR_ACCOUNT_NAME}:${EMULATOR_ACCOUNT_KEY_STR}:${EMULATOR_ACCOUNT_KEY2_STR};${EMULATOR_ACCOUNT2_NAME}:${EMULATOR_ACCOUNT2_KEY_STR}`; const factory = new BlobTestServerFactory(); const server = factory.createServer(); @@ -81,10 +80,7 @@ describe("Shared Access Signature (SAS) authentication", () => { const serviceClientInvalid = new BlobServiceClient( baseURL, newPipeline( - new StorageSharedKeyCredential( - EMULATOR_ACCOUNT_NAME, - "invalidKey" - ), + new StorageSharedKeyCredential(EMULATOR_ACCOUNT_NAME, "invalidKey"), { retryOptions: { maxTries: 1 }, // Make sure socket is closed once the operation is done. @@ -119,11 +115,11 @@ describe("Shared Access Signature (SAS) authentication", () => { }); it("generateAccountSASQueryParameters should generate correct hashes", async () => { - const startDate = new Date(Date.UTC(2022, 3, 16, 6, 31, 48, 0)); const endDate = new Date(Date.UTC(2022, 3, 17, 6, 31, 48, 0)); - const storageSharedKeyCredential = (serviceClient as any).credential as StorageSharedKeyCredential; + const storageSharedKeyCredential = (serviceClient as any) + .credential as StorageSharedKeyCredential; const sas = generateAccountSASQueryParameters( { @@ -139,7 +135,10 @@ describe("Shared Access Signature (SAS) authentication", () => { storageSharedKeyCredential as StorageSharedKeyCredential ).toString(); - assert.equal(sas, "sv=2016-05-31&ss=btqf&srt=sco&spr=https%2Chttp&st=2022-04-16T06%3A31%3A48Z&se=2022-04-17T06%3A31%3A48Z&sip=0.0.0.0-255.255.255.255&sp=rwdlacup&sig=bTjnB%2ByT4DkYQcmMfpedew72%2FKdvTMuz29uatYuWYME%3D"); + assert.equal( + sas, + "sv=2016-05-31&ss=btqf&srt=sco&spr=https%2Chttp&st=2022-04-16T06%3A31%3A48Z&se=2022-04-17T06%3A31%3A48Z&sip=0.0.0.0-255.255.255.255&sp=rwdlacup&sig=bTjnB%2ByT4DkYQcmMfpedew72%2FKdvTMuz29uatYuWYME%3D" + ); const sas2 = generateAccountSASQueryParameters( { @@ -155,7 +154,10 @@ describe("Shared Access Signature (SAS) authentication", () => { storageSharedKeyCredential as StorageSharedKeyCredential ).toString(); - assert.equal(sas2, "sv=2018-11-09&ss=btqf&srt=sco&spr=https%2Chttp&st=2022-04-16T06%3A31%3A48Z&se=2022-04-17T06%3A31%3A48Z&sip=0.0.0.0-255.255.255.255&sp=rwdlacup&sig=zty6%2BwJgp86CuAlzq9w%2F%2BavVwjoEHgBOTXXmN7xAyio%3D"); + assert.equal( + sas2, + "sv=2018-11-09&ss=btqf&srt=sco&spr=https%2Chttp&st=2022-04-16T06%3A31%3A48Z&se=2022-04-17T06%3A31%3A48Z&sip=0.0.0.0-255.255.255.255&sp=rwdlacup&sig=zty6%2BwJgp86CuAlzq9w%2F%2BavVwjoEHgBOTXXmN7xAyio%3D" + ); const sas3 = generateAccountSASQueryParameters( { @@ -171,7 +173,10 @@ describe("Shared Access Signature (SAS) authentication", () => { storageSharedKeyCredential as StorageSharedKeyCredential ).toString(); - assert.equal(sas3, "sv=2020-12-06&ss=btqf&srt=sco&spr=https%2Chttp&st=2022-04-16T06%3A31%3A48Z&se=2022-04-17T06%3A31%3A48Z&sip=0.0.0.0-255.255.255.255&sp=rwdlacup&sig=QB5jKCJTqn7jcPidkkXAazBQBL6VDMY7HumUIM78ROE%3D"); + assert.equal( + sas3, + "sv=2020-12-06&ss=btqf&srt=sco&spr=https%2Chttp&st=2022-04-16T06%3A31%3A48Z&se=2022-04-17T06%3A31%3A48Z&sip=0.0.0.0-255.255.255.255&sp=rwdlacup&sig=QB5jKCJTqn7jcPidkkXAazBQBL6VDMY7HumUIM78ROE%3D" + ); }); it("generateAccountSASQueryParameters should work @loki @sql", async () => { @@ -362,6 +367,45 @@ describe("Shared Access Signature (SAS) authentication", () => { assert.ok(error); }); + it("generateAccountSASQueryParameters should reject duplicate SAS signature query @loki @sql", async () => { + const tmr = new Date(); + tmr.setDate(tmr.getDate() + 1); + + const storageSharedKeyCredential = (serviceClient as any).credential; + + const sas = generateAccountSASQueryParameters( + { + expiresOn: tmr, + permissions: AccountSASPermissions.parse("rwdlacup"), + resourceTypes: AccountSASResourceTypes.parse("sco").toString(), + services: AccountSASServices.parse("btqf").toString(), + protocol: SASProtocol.HttpsAndHttp, + version: "2016-05-31" + }, + storageSharedKeyCredential as StorageSharedKeyCredential + ).toString(); + + // Duplicate a critical auth field to emulate ambiguous query parsing cases. + const sasWithDuplicateSig = `${sas}&sig=duplicated-signature`; + const sasURL = `${serviceClient.url}?${sasWithDuplicateSig}`; + const serviceClientWithSAS = new BlobServiceClient( + sasURL, + newPipeline(new AnonymousCredential(), { + keepAliveOptions: { enable: false } + }) + ); + + let error; + try { + await serviceClientWithSAS.getProperties(); + } catch (err) { + error = err; + } + + assert.ok(error); + assert.deepStrictEqual(error.statusCode, 403); + }); + it("Synchronized copy blob should work with write permission in account SAS to override an existing blob @loki", async () => { const tmr = new Date(); tmr.setDate(tmr.getDate() + 1); @@ -391,9 +435,8 @@ describe("Shared Access Signature (SAS) authentication", () => { ); const containerName = getUniqueName("con"); - const containerClient = serviceClientWithSAS.getContainerClient( - containerName - ); + const containerClient = + serviceClientWithSAS.getContainerClient(containerName); await containerClient.create(); const blobName1 = getUniqueName("blob"); @@ -437,9 +480,8 @@ describe("Shared Access Signature (SAS) authentication", () => { ); const containerName = getUniqueName("con"); - const containerClient = serviceClientWithSAS.getContainerClient( - containerName - ); + const containerClient = + serviceClientWithSAS.getContainerClient(containerName); await containerClient.create(); const blobName1 = getUniqueName("blob"); @@ -490,9 +532,8 @@ describe("Shared Access Signature (SAS) authentication", () => { ); const containerName = getUniqueName("con"); - const containerClient = serviceClientWithSAS.getContainerClient( - containerName - ); + const containerClient = + serviceClientWithSAS.getContainerClient(containerName); await containerClient.create(); const blobName1 = getUniqueName("blob"); @@ -535,9 +576,8 @@ describe("Shared Access Signature (SAS) authentication", () => { ); const containerName = getUniqueName("con"); - const containerClient = serviceClientWithSAS.getContainerClient( - containerName - ); + const containerClient = + serviceClientWithSAS.getContainerClient(containerName); await containerClient.create(); const blobName1 = getUniqueName("blob"); @@ -581,9 +621,8 @@ describe("Shared Access Signature (SAS) authentication", () => { ); const containerName = getUniqueName("con"); - const containerClient = serviceClientWithSAS.getContainerClient( - containerName - ); + const containerClient = + serviceClientWithSAS.getContainerClient(containerName); await containerClient.create(); const blobName1 = getUniqueName("blob"); @@ -635,9 +674,8 @@ describe("Shared Access Signature (SAS) authentication", () => { const containerName = getUniqueName("con"); const containerClient = serviceClient.getContainerClient(containerName); - const containerClientWithSAS = serviceClientWithSAS.getContainerClient( - containerName - ); + const containerClientWithSAS = + serviceClientWithSAS.getContainerClient(containerName); await containerClient.create(); const blobName1 = getUniqueName("blob"); @@ -731,7 +769,9 @@ describe("Shared Access Signature (SAS) authentication", () => { assert.deepStrictEqual(err.statusCode, 403); } - const serviceClientWithSAS = new BlobServiceClient(`${serviceClient.url}?${containerSAS}`); + const serviceClientWithSAS = new BlobServiceClient( + `${serviceClient.url}?${containerSAS}` + ); try { await serviceClientWithSAS.getAccountInfo(); @@ -792,7 +832,8 @@ describe("Shared Access Signature (SAS) authentication", () => { assert.deepStrictEqual(err.statusCode, 403); } - const serviceClientWithSAS = new BlobServiceClient(`${serviceClient.url}?${blobSAS}`, + const serviceClientWithSAS = new BlobServiceClient( + `${serviceClient.url}?${blobSAS}`, newPipeline(new AnonymousCredential()) ); @@ -835,8 +876,10 @@ describe("Shared Access Signature (SAS) authentication", () => { storageSharedKeyCredential as StorageSharedKeyCredential ); - const serviceClientWithSAS = new BlobServiceClient(`${serviceClient.url}?${blobSAS}`, - new AnonymousCredential()); + const serviceClientWithSAS = new BlobServiceClient( + `${serviceClient.url}?${blobSAS}`, + new AnonymousCredential() + ); const blobBatchClient = serviceClientWithSAS.getBlobBatchClient(); // Assemble batch delete request. @@ -881,8 +924,10 @@ describe("Shared Access Signature (SAS) authentication", () => { storageSharedKeyCredential as StorageSharedKeyCredential ); - const containerClientWithSAS = new ContainerClient(`${serviceClient.url}?${blobSAS}`, - new AnonymousCredential()); + const containerClientWithSAS = new ContainerClient( + `${serviceClient.url}?${blobSAS}`, + new AnonymousCredential() + ); const blobBatchClient = containerClientWithSAS.getBlobBatchClient(); // Assemble batch delete request. @@ -932,7 +977,7 @@ describe("Shared Access Signature (SAS) authentication", () => { let error; try { - await containerClientWithSAS.getBlobClient('blob').deleteIfExists(); + await containerClientWithSAS.getBlobClient("blob").deleteIfExists(); } catch (err) { error = err; } finally { @@ -953,7 +998,8 @@ describe("Shared Access Signature (SAS) authentication", () => { const tmr = new Date(); tmr.setDate(tmr.getDate() + 1); - const storageSharedKeyCredential = (serviceClientSecondKey as any).credential; + const storageSharedKeyCredential = (serviceClientSecondKey as any) + .credential; const containerName = getUniqueName("container"); const containerClient = serviceClient.getContainerClient(containerName); @@ -978,7 +1024,7 @@ describe("Shared Access Signature (SAS) authentication", () => { newPipeline(new AnonymousCredential()) ); - await containerClientWithSAS.getBlobClient('blob').deleteIfExists(); + await containerClientWithSAS.getBlobClient("blob").deleteIfExists(); await containerClient.delete(); }); @@ -1038,7 +1084,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const downloadResponse = await blobClientWithSAS.download(); assert.equal(downloadResponse.cacheControl, "cache-control-original"); - assert.equal(downloadResponse.contentDisposition, "content-disposition-original"); + assert.equal( + downloadResponse.contentDisposition, + "content-disposition-original" + ); assert.equal(downloadResponse.contentEncoding, "content-encoding-original"); assert.equal(downloadResponse.contentLanguage, "content-language-original"); assert.equal(downloadResponse.contentType, "content-type-original"); @@ -1059,7 +1108,7 @@ describe("Shared Access Signature (SAS) authentication", () => { const containerClient = serviceClient.getContainerClient(containerName); await containerClient.create(); - const blobName = "this is a test file Ж 大 仮.jpg"; //filename contains spaces and special characters + const blobName = "this is a test file Ж 大 仮.jpg"; //filename contains spaces and special characters const blobClient = containerClient.getPageBlobClient(blobName); await blobClient.create(1024, { blobHTTPHeaders: { @@ -1098,14 +1147,20 @@ describe("Shared Access Signature (SAS) authentication", () => { const properties = await blobClientWithSAS.getProperties(); assert.equal(properties.cacheControl, "cache-control-original"); - assert.equal(properties.contentDisposition, `attachment; filename=\"${escapedblobName}\"; filename*=UTF-8''${escapedblobName}`); + assert.equal( + properties.contentDisposition, + `attachment; filename=\"${escapedblobName}\"; filename*=UTF-8''${escapedblobName}` + ); assert.equal(properties.contentEncoding, "content-encoding-original"); assert.equal(properties.contentLanguage, "content-language-original"); assert.equal(properties.contentType, "content-type-original"); const downloadResponse = await blobClientWithSAS.download(); assert.equal(downloadResponse.cacheControl, "cache-control-original"); - assert.equal(downloadResponse.contentDisposition, `attachment; filename=\"${escapedblobName}\"; filename*=UTF-8''${escapedblobName}`); + assert.equal( + downloadResponse.contentDisposition, + `attachment; filename=\"${escapedblobName}\"; filename*=UTF-8''${escapedblobName}` + ); assert.equal(downloadResponse.contentEncoding, "content-encoding-original"); assert.equal(downloadResponse.contentLanguage, "content-language-original"); assert.equal(downloadResponse.contentType, "content-type-original"); @@ -1170,7 +1225,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const downloadResponse = await blobClientWithSAS.download(); assert.equal(downloadResponse.cacheControl, "cache-control-override"); - assert.equal(downloadResponse.contentDisposition, "content-disposition-override"); + assert.equal( + downloadResponse.contentDisposition, + "content-disposition-override" + ); assert.equal(downloadResponse.contentEncoding, "content-encoding-override"); assert.equal(downloadResponse.contentLanguage, "content-language-override"); assert.equal(downloadResponse.contentType, "content-type-override"); @@ -1234,7 +1292,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const downloadResponse = await blobClientWithSAS.download(); assert.equal(downloadResponse.cacheControl, "cache-control-original"); - assert.equal(downloadResponse.contentDisposition, "content-disposition-original"); + assert.equal( + downloadResponse.contentDisposition, + "content-disposition-original" + ); assert.equal(downloadResponse.contentEncoding, "content-encoding-original"); assert.equal(downloadResponse.contentLanguage, "content-language-original"); assert.equal(downloadResponse.contentType, "content-type-original"); @@ -1299,7 +1360,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const downloadResponse = await blobClientWithSAS.download(); assert.equal(downloadResponse.cacheControl, "cache-control-override"); - assert.equal(downloadResponse.contentDisposition, "content-disposition-override"); + assert.equal( + downloadResponse.contentDisposition, + "content-disposition-override" + ); assert.equal(downloadResponse.contentEncoding, "content-encoding-override"); assert.equal(downloadResponse.contentLanguage, "content-language-override"); assert.equal(downloadResponse.contentType, "content-type-override"); @@ -1368,7 +1432,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const downloadResponse = await blobClientWithSAS.download(); assert.equal(downloadResponse.cacheControl, "cache-control-override"); - assert.equal(downloadResponse.contentDisposition, "content-disposition-override"); + assert.equal( + downloadResponse.contentDisposition, + "content-disposition-override" + ); assert.equal(downloadResponse.contentEncoding, "content-encoding-override"); assert.equal(downloadResponse.contentLanguage, "content-language-override"); assert.equal(downloadResponse.contentType, "content-type-override"); @@ -1748,8 +1815,9 @@ describe("Shared Access Signature (SAS) authentication", () => { storageSharedKeyCredential as StorageSharedKeyCredential ); - const sasURL = `${blobClient.withSnapshot(response.snapshot!).url - }&${blobSAS}`; + const sasURL = `${ + blobClient.withSnapshot(response.snapshot!).url + }&${blobSAS}`; const blobClientWithSAS = new PageBlobClient( sasURL, newPipeline(new AnonymousCredential()) @@ -1765,7 +1833,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const downloadResponse = await blobClientWithSAS.download(); assert.equal(downloadResponse.cacheControl, "cache-control-override"); - assert.equal(downloadResponse.contentDisposition, "content-disposition-override"); + assert.equal( + downloadResponse.contentDisposition, + "content-disposition-override" + ); assert.equal(downloadResponse.contentEncoding, "content-encoding-override"); assert.equal(downloadResponse.contentLanguage, "content-language-override"); assert.equal(downloadResponse.contentType, "content-type-override"); @@ -1783,12 +1854,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const sourceStorageSharedKeyCredential = (serviceClient as any).credential; const containerName = getUniqueName("con"); - const sourceContainerClient = serviceClient.getContainerClient( - containerName - ); - const targetContainerClient = serviceClient2.getContainerClient( - containerName - ); + const sourceContainerClient = + serviceClient.getContainerClient(containerName); + const targetContainerClient = + serviceClient2.getContainerClient(containerName); await sourceContainerClient.create(); await targetContainerClient.create(); @@ -1841,12 +1910,10 @@ describe("Shared Access Signature (SAS) authentication", () => { tmr.setDate(tmr.getDate() + 1); const containerName = getUniqueName("con"); - const sourceContainerClient = serviceClient.getContainerClient( - containerName - ); - const targetContainerClient = serviceClient2.getContainerClient( - containerName - ); + const sourceContainerClient = + serviceClient.getContainerClient(containerName); + const targetContainerClient = + serviceClient2.getContainerClient(containerName); await sourceContainerClient.create(); await targetContainerClient.create(); @@ -1876,12 +1943,10 @@ describe("Shared Access Signature (SAS) authentication", () => { tmr.setDate(tmr.getDate() + 1); const containerName = getUniqueName("con"); - const sourceContainerClient = serviceClient.getContainerClient( - containerName - ); - const targetContainerClient = serviceClient2.getContainerClient( - containerName - ); + const sourceContainerClient = + serviceClient.getContainerClient(containerName); + const targetContainerClient = + serviceClient2.getContainerClient(containerName); await sourceContainerClient.create({ access: "blob" }); @@ -1907,12 +1972,10 @@ describe("Shared Access Signature (SAS) authentication", () => { tmr.setDate(tmr.getDate() + 1); const containerName = getUniqueName("con"); - const sourceContainerClient = serviceClient.getContainerClient( - containerName - ); - const targetContainerClient = serviceClient2.getContainerClient( - containerName - ); + const sourceContainerClient = + serviceClient.getContainerClient(containerName); + const targetContainerClient = + serviceClient2.getContainerClient(containerName); await sourceContainerClient.create({ access: "container" }); @@ -1940,12 +2003,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const sourceStorageSharedKeyCredential = (serviceClient as any).credential; const containerName = getUniqueName("con"); - const sourceContainerClient = serviceClient.getContainerClient( - containerName - ); - const targetContainerClient = serviceClient2.getContainerClient( - containerName - ); + const sourceContainerClient = + serviceClient.getContainerClient(containerName); + const targetContainerClient = + serviceClient2.getContainerClient(containerName); await sourceContainerClient.create(); await targetContainerClient.create(); @@ -1983,9 +2044,8 @@ describe("Shared Access Signature (SAS) authentication", () => { assert.equal(properties.metadata!["foo"], "1"); assert.equal(properties.metadata!["bar"], "2"); - const targetBlobWithProps = targetContainerClient.getBlockBlobClient( - blobName2 - ); + const targetBlobWithProps = + targetContainerClient.getBlockBlobClient(blobName2); const operation2 = await targetBlobWithProps.beginCopyFromURL( `${sourceBlob.url}?${sas}`, { @@ -2013,12 +2073,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const sourceStorageSharedKeyCredential = (serviceClient as any).credential; const containerName = getUniqueName("con"); - const sourceContainerClient = serviceClient.getContainerClient( - containerName - ); - const targetContainerClient = serviceClient2.getContainerClient( - containerName - ); + const sourceContainerClient = + serviceClient.getContainerClient(containerName); + const targetContainerClient = + serviceClient2.getContainerClient(containerName); await sourceContainerClient.create(); await targetContainerClient.create(); @@ -2064,12 +2122,10 @@ describe("Shared Access Signature (SAS) authentication", () => { const sourceStorageSharedKeyCredential = (serviceClient as any).credential; const containerName = getUniqueName("con"); - const sourceContainerClient = serviceClient.getContainerClient( - containerName - ); - const targetContainerClient = serviceClient2.getContainerClient( - containerName - ); + const sourceContainerClient = + serviceClient.getContainerClient(containerName); + const targetContainerClient = + serviceClient2.getContainerClient(containerName); await sourceContainerClient.create(); await targetContainerClient.create(); @@ -2099,9 +2155,8 @@ describe("Shared Access Signature (SAS) authentication", () => { // Copy From URI const targetBlob = targetContainerClient.getBlockBlobClient(blobName); - const targetBlobWithProps = targetContainerClient.getBlockBlobClient( - blobName2 - ); + const targetBlobWithProps = + targetContainerClient.getBlockBlobClient(blobName2); const copyResponse3 = await targetBlob.syncCopyFromURL( `${sourceBlob.url}?${sas}` ); @@ -2136,7 +2191,7 @@ describe("Shared Access Signature (SAS) authentication", () => { const tags = { tag1: "val1", - tag2: "val2", + tag2: "val2" }; const blockBlockName = getUniqueName("blockblob"); @@ -2146,16 +2201,18 @@ describe("Shared Access Signature (SAS) authentication", () => { const sasURL = await containerClient.generateSasUrl({ expiresOn: tmr, permissions: ContainerSASPermissions.parse("f"), - protocol: SASProtocol.HttpsAndHttp, + protocol: SASProtocol.HttpsAndHttp }); const containerClientWithSAS = new ContainerClient(sasURL); const expectedTags1: Tags = { - tag1: "val1", + tag1: "val1" }; - for await (const blob of containerClientWithSAS.findBlobsByTags(`tag1='val1'`)) { + for await (const blob of containerClientWithSAS.findBlobsByTags( + `tag1='val1'` + )) { assert.deepStrictEqual(blob.name, blockBlockName); assert.deepStrictEqual(blob.tags, expectedTags1); assert.deepStrictEqual(blob.tagValue, "val1"); @@ -2174,7 +2231,7 @@ describe("Shared Access Signature (SAS) authentication", () => { const tags = { tag1: "val1", - tag2: "val2", + tag2: "val2" }; const blockBlobName = getUniqueName("blockblob"); @@ -2183,22 +2240,28 @@ describe("Shared Access Signature (SAS) authentication", () => { const sourceStorageSharedKeyCredential = (serviceClient as any).credential; - const sasURL = generateAccountSASQueryParameters({ - expiresOn: tmr, - services: 'b', - resourceTypes: 'so', - permissions: AccountSASPermissions.parse("f"), - protocol: SASProtocol.HttpsAndHttp, - }, - sourceStorageSharedKeyCredential).toString(); + const sasURL = generateAccountSASQueryParameters( + { + expiresOn: tmr, + services: "b", + resourceTypes: "so", + permissions: AccountSASPermissions.parse("f"), + protocol: SASProtocol.HttpsAndHttp + }, + sourceStorageSharedKeyCredential + ).toString(); - const serviceClientWithSAS = new BlobServiceClient(`${serviceClient.url}?${sasURL}`); + const serviceClientWithSAS = new BlobServiceClient( + `${serviceClient.url}?${sasURL}` + ); const expectedTags1: Tags = { - tag1: "val1", + tag1: "val1" }; - for await (const blob of serviceClientWithSAS.findBlobsByTags(`tag1='val1'`)) { + for await (const blob of serviceClientWithSAS.findBlobsByTags( + `tag1='val1'` + )) { assert.deepStrictEqual(blob.tags, expectedTags1); assert.deepStrictEqual(blob.tagValue, "val1"); } @@ -2216,7 +2279,7 @@ describe("Shared Access Signature (SAS) authentication", () => { const tags = { tag1: "val1", - tag2: "val2", + tag2: "val2" }; const blockBlobName = getUniqueName("blockblob"); @@ -2225,22 +2288,28 @@ describe("Shared Access Signature (SAS) authentication", () => { const sourceStorageSharedKeyCredential = (serviceClient as any).credential; - const sasURL = generateAccountSASQueryParameters({ - expiresOn: tmr, - services: 'b', - resourceTypes: 'c', - permissions: AccountSASPermissions.parse("f"), - protocol: SASProtocol.HttpsAndHttp, - }, - sourceStorageSharedKeyCredential).toString(); + const sasURL = generateAccountSASQueryParameters( + { + expiresOn: tmr, + services: "b", + resourceTypes: "c", + permissions: AccountSASPermissions.parse("f"), + protocol: SASProtocol.HttpsAndHttp + }, + sourceStorageSharedKeyCredential + ).toString(); - const containerClientWithSas = new ContainerClient(`${containerClient.url}?${sasURL}`); + const containerClientWithSas = new ContainerClient( + `${containerClient.url}?${sasURL}` + ); const expectedTags1: Tags = { - tag1: "val1", + tag1: "val1" }; - for await (const blob of containerClientWithSas.findBlobsByTags(`tag1='val1'`)) { + for await (const blob of containerClientWithSas.findBlobsByTags( + `tag1='val1'` + )) { assert.deepStrictEqual(blob.tags, expectedTags1); assert.deepStrictEqual(blob.tagValue, "val1"); } @@ -2258,7 +2327,7 @@ describe("Shared Access Signature (SAS) authentication", () => { const tags = { tag1: "val1", - tag2: "val2", + tag2: "val2" }; const blockBlobName = getUniqueName("blockblob"); @@ -2268,7 +2337,7 @@ describe("Shared Access Signature (SAS) authentication", () => { const sasURL = await blockBlobClient.generateSasUrl({ expiresOn: tmr, permissions: BlobSASPermissions.parse("t"), - protocol: SASProtocol.HttpsAndHttp, + protocol: SASProtocol.HttpsAndHttp }); const blobClientWithSAS = new BlobClient(sasURL); @@ -2290,7 +2359,7 @@ describe("Shared Access Signature (SAS) authentication", () => { const tags = { tag1: "val1", - tag2: "val2", + tag2: "val2" }; const blockBlobName = getUniqueName("blockblob"); @@ -2299,16 +2368,20 @@ describe("Shared Access Signature (SAS) authentication", () => { const sourceStorageSharedKeyCredential = (serviceClient as any).credential; - const sasURL = generateAccountSASQueryParameters({ - expiresOn: tmr, - services: 'b', - resourceTypes: 'o', - permissions: AccountSASPermissions.parse("t"), - protocol: SASProtocol.HttpsAndHttp, - }, - sourceStorageSharedKeyCredential).toString(); - - const blobClientWithSAS = new BlobClient(`${blockBlobClient.url}?${sasURL}`); + const sasURL = generateAccountSASQueryParameters( + { + expiresOn: tmr, + services: "b", + resourceTypes: "o", + permissions: AccountSASPermissions.parse("t"), + protocol: SASProtocol.HttpsAndHttp + }, + sourceStorageSharedKeyCredential + ).toString(); + + const blobClientWithSAS = new BlobClient( + `${blockBlobClient.url}?${sasURL}` + ); await blobClientWithSAS.setTags(tags); const getTagsResult = await blobClientWithSAS.getTags(); diff --git a/tests/blob/startupErrorRecovery.test.ts b/tests/blob/startupErrorRecovery.test.ts new file mode 100644 index 000000000..d153ace82 --- /dev/null +++ b/tests/blob/startupErrorRecovery.test.ts @@ -0,0 +1,226 @@ +import * as assert from "assert"; +import * as fs from "fs-extra"; + +import BlobConfiguration from "../../src/blob/BlobConfiguration"; +import BlobServer from "../../src/blob/BlobServer"; +import { configLogger } from "../../src/common/Logger"; +import { DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT } from "../../src/blob/utils/constants"; +import { ServerStatus } from "../../src/common/ServerBase"; + +// Set true to enable debug log +configLogger(false); + +describe("Blob Server Startup Error Recovery - Issue #2672 @loki", () => { + const testDbPath = "__test_startup_error_db_blob__.json"; + const testDbExtentPath = "__test_startup_error_db_blob_extent__.json"; + const blobStoragePath = "__test_startup_error_blobstorage__"; + + async function startWithTimeout(server: BlobServer): Promise { + let timeout: ReturnType | undefined; + + try { + await Promise.race([ + server.start(), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error("Server startup timeout after 10 seconds")); + }, 10000); + + const nodeTimer = timeout as unknown as { unref?: () => void }; + if (typeof nodeTimer.unref === "function") { + nodeTimer.unref(); + } + }) + ]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } + } + + after(async () => { + // Clean up test artifacts + if (fs.existsSync(testDbPath)) { + fs.removeSync(testDbPath); + } + if (fs.existsSync(testDbExtentPath)) { + fs.removeSync(testDbExtentPath); + } + if (fs.existsSync(blobStoragePath)) { + fs.removeSync(blobStoragePath); + } + }); + + it("should start successfully when no persisted data exists (fresh start)", async () => { + // Clean paths before test + [testDbPath, testDbExtentPath, blobStoragePath].forEach((p) => { + if (fs.existsSync(p)) { + fs.removeSync(p); + } + }); + + const config = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + testDbPath, + testDbExtentPath, + [ + { + locationId: "test", + locationPath: blobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const server = new BlobServer(config); + + try { + // Should start without errors + await server.start(); + assert.strictEqual(server.getStatus(), ServerStatus.Running); + + await server.close(); + assert.strictEqual(server.getStatus(), ServerStatus.Closed); + } finally { + await server.clean(); + } + }); + + it("should handle GC startup errors gracefully without throwing 'Cannot close server in status Starting'", async () => { + // Clean paths before test + [testDbPath, testDbExtentPath, blobStoragePath].forEach((p) => { + if (fs.existsSync(p)) { + fs.removeSync(p); + } + }); + + // Create a corrupted metadata file to simulate legacy data that causes GC errors + // This simulates loading data from 3.35.0 that causes compatibility issues + const corruptedMetadata = { + schemaVersion: "3.35.0", + data: { + collections: [ + { + name: "containers", + binaryIndices: [{ name: "name", keyType: "string" }], + data: [ + { + // Minimal container entry - may cause issues during GC init + name: "test-container", + meta: { revision: 0, created: 1600000000000, version: 0 } + } + ] + } + ] + } + }; + + // Write corrupted metadata to simulate legacy data + fs.writeFileSync(testDbPath, JSON.stringify(corruptedMetadata, null, 2)); + + const config = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + testDbPath, + testDbExtentPath, + [ + { + locationId: "test", + locationPath: blobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const server = new BlobServer(config); + try { + // Attempt to start server + await startWithTimeout(server); + + // If we get here, startup completed and should no longer be stuck in Starting. + assert.notStrictEqual( + server.getStatus(), + ServerStatus.Starting, + "Server should not remain in Starting state after startup completes" + ); + + // Try to close gracefully + if (server.getStatus() === ServerStatus.Running) { + await server.close(); + } + } catch (err) { + if (!(err instanceof Error)) { + throw err; + } + + if (err.message.includes("Cannot close server in status Starting")) { + assert.fail( + `Bug not fixed: Server attempted to close while in Starting state. Error: ${err.message}` + ); + } + + // Do not swallow startup failures such as timeout/hang. + throw err; + } finally { + try { + await server.clean(); + } catch { + // Cleanup may fail if startup failed, that's ok + } + } + }); + + it("should successfully recover from concurrent startup and GC initialization", async () => { + // Clean paths before test + [testDbPath, testDbExtentPath, blobStoragePath].forEach((p) => { + if (fs.existsSync(p)) { + fs.removeSync(p); + } + }); + + const config = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + testDbPath, + testDbExtentPath, + [ + { + locationId: "test", + locationPath: blobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const server = new BlobServer(config); + + try { + // Start the server - GC manager will initialize concurrently during startup + await server.start(); + + // Verify server reached Running state + assert.strictEqual( + server.getStatus(), + ServerStatus.Running, + "Server should be in Running status after successful start" + ); + + // Give GC some time to run + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Close should work without issues + await server.close(); + assert.strictEqual(server.getStatus(), ServerStatus.Closed); + } finally { + await server.clean(); + } + }); +}); diff --git a/tests/blob/unit/gcErrorHandler.unit.test.ts b/tests/blob/unit/gcErrorHandler.unit.test.ts new file mode 100644 index 000000000..c61cb6941 --- /dev/null +++ b/tests/blob/unit/gcErrorHandler.unit.test.ts @@ -0,0 +1,118 @@ +import * as assert from "assert"; + +import { handleGCCriticalErrorClose } from "../../../src/common/GCCriticalErrorCloseHelper"; +import ILogger from "../../../src/common/ILogger"; +import { ServerStatus } from "../../../src/common/ServerBase"; + +describe("GC Error Handler Helper Unit Tests - Issue #2672 @loki", () => { + function createTestLogger(events: string[]): ILogger { + return { + error: (message: string) => events.push(`error:${message}`), + warn: (message: string) => events.push(`warn:${message}`), + info: (message: string) => events.push(`info:${message}`), + verbose: () => { + // no-op for these tests + }, + debug: () => { + // no-op for these tests + } + }; + } + + it("waits for Running status, then closes", async () => { + let serverStatus: ServerStatus = ServerStatus.Starting; + let closeCalled = false; + const events: string[] = []; + + const transitionToRunning = new Promise((resolve) => { + setTimeout(() => { + serverStatus = ServerStatus.Running; + events.push("transitioned_to_running"); + resolve(); + }, 20); + }); + + await Promise.all([ + transitionToRunning, + handleGCCriticalErrorClose({ + serviceName: "Blob", + getStatus: () => serverStatus, + close: async () => { + closeCalled = true; + events.push("close_called"); + }, + logger: createTestLogger(events), + waitIntervalMs: 5, + maxAttempts: 20 + }) + ]); + + assert.strictEqual(closeCalled, true, "close should be called"); + assert.ok( + events.some((e) => + e.includes("Shutting down Blob server due to GC critical error") + ), + "should log shutdown intent when status becomes Running" + ); + }); + + it("does not close if server transitions from Starting to Closed", async () => { + let serverStatus: ServerStatus = ServerStatus.Starting; + let closeCalled = false; + const events: string[] = []; + + // Ensure this happens well after maxAttempts * waitIntervalMs so timeout logging is deterministic. + setTimeout(() => { + serverStatus = ServerStatus.Closed; + events.push("transitioned_to_closed"); + }, 60); + + await handleGCCriticalErrorClose({ + serviceName: "Queue", + getStatus: () => serverStatus, + close: async () => { + closeCalled = true; + }, + logger: createTestLogger(events), + waitIntervalMs: 5, + maxAttempts: 3 + }); + + assert.strictEqual(closeCalled, false, "close should not be called"); + assert.ok( + events.some((e) => e.includes("did not transition to Running state")), + "should log startup transition timeout" + ); + assert.ok( + events.some((e) => + e.includes("Queue server status is Closed (expected Running)") + ), + "should log status mismatch warning" + ); + }); + + it("swallows close errors and logs diagnostics", async () => { + let serverStatus: ServerStatus = ServerStatus.Running; + const events: string[] = []; + + await handleGCCriticalErrorClose({ + serviceName: "Blob", + getStatus: () => serverStatus, + close: async () => { + throw new Error("synthetic close failure"); + }, + logger: createTestLogger(events), + waitIntervalMs: 5, + maxAttempts: 1 + }); + + assert.ok( + events.some((e) => + e.includes( + "Blob server error during GC error handling: synthetic close failure" + ) + ), + "should log close diagnostics instead of throwing" + ); + }); +}); diff --git a/tests/blob/upgradeRegression.test.ts b/tests/blob/upgradeRegression.test.ts new file mode 100644 index 000000000..ef44b979d --- /dev/null +++ b/tests/blob/upgradeRegression.test.ts @@ -0,0 +1,660 @@ +import { + newPipeline, + BlobServiceClient, + StorageSharedKeyCredential, + ContainerClient +} from "@azure/storage-blob"; +import * as assert from "assert"; +import * as nodefs from "fs"; +import * as fs from "fs-extra"; + +import BlobConfiguration from "../../src/blob/BlobConfiguration"; +import BlobServer from "../../src/blob/BlobServer"; +import { configLogger } from "../../src/common/Logger"; +import { DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT } from "../../src/blob/utils/constants"; +import { ServerStatus } from "../../src/common/ServerBase"; +import { EMULATOR_ACCOUNT_KEY, EMULATOR_ACCOUNT_NAME } from "../testutils"; + +// Set true to enable debug log +configLogger(false); + +describe("Azurite Upgrade Regression Tests @loki", () => { + let upgradeTestDbPath = ""; + let upgradeTestDbExtentPath = ""; + let upgradeBlobStoragePath = ""; + const cleanupTargets: string[] = []; + const containerName = "upgrade-test-container"; + const blobName = "upgrade-test-blob.txt"; + const blobContent = "This data was created in Azurite 3.35.0"; + + function allocatePaths(scope: string): void { + const token = `${scope}_${Date.now()}_${Math.random() + .toString(16) + .slice(2, 10)}`; + + upgradeTestDbPath = `__test_upgrade_db_blob__${token}.json`; + upgradeTestDbExtentPath = `__test_upgrade_db_blob_extent__${token}.json`; + upgradeBlobStoragePath = `__test_upgrade_blobstorage__${token}`; + + cleanupTargets.push( + upgradeTestDbPath, + upgradeTestDbExtentPath, + upgradeBlobStoragePath + ); + } + + function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + async function removePathWithRetry(path: string): Promise { + try { + await nodefs.promises.rm(path, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100 + }); + return; + } catch (err) { + const code = (err as { code?: string })?.code; + + if (code === "ENOENT") { + return; + } + + // Last fallback for sporadic Windows lock races. + await delay(200); + await fs.remove(path); + } + } + + before(async () => { + allocatePaths("suite"); + }); + + beforeEach(() => { + allocatePaths("case"); + }); + + after(async () => { + // Clean up test artifacts + await cleanUpgradeArtifacts(); + }); + + async function cleanUpgradeArtifacts(): Promise { + const paths = Array.from(new Set(cleanupTargets)); + + for (const p of paths) { + try { + await removePathWithRetry(p); + } catch (err) { + const code = (err as { code?: string })?.code; + if (code !== "EPERM") { + throw err; + } + } + } + } + + function extractMd5Bytes(contentMD5: any): number[] { + if (contentMD5 === undefined || contentMD5 === null) { + return []; + } + + if (contentMD5.type === "Buffer" && Array.isArray(contentMD5.data)) { + return contentMD5.data; + } + + if (Array.isArray(contentMD5)) { + return contentMD5; + } + + return Object.keys(contentMD5) + .sort((a, b) => Number(a) - Number(b)) + .map((k) => contentMD5[k]); + } + + function rewritePersistedMd5Shape( + shape: "buffer-json" | "numeric-object" | "plain-array" + ): void { + const db = fs.readJSONSync(upgradeTestDbPath); + const blobCollection = db.collections.find( + (c: any) => c.name === "$BLOBS_COLLECTION$" + ); + + assert.ok(blobCollection !== undefined, "Blob collection should exist"); + + for (const doc of blobCollection.data) { + const existing = doc.properties?.contentMD5; + const bytes = extractMd5Bytes(existing); + assert.ok(bytes.length > 0, "Persisted contentMD5 bytes should exist"); + + if (shape === "buffer-json") { + doc.properties.contentMD5 = { type: "Buffer", data: bytes }; + } else if (shape === "numeric-object") { + const numericObj: { [k: string]: number } = {}; + bytes.forEach((v, i) => { + numericObj[String(i)] = v; + }); + doc.properties.contentMD5 = numericObj; + } else { + doc.properties.contentMD5 = bytes; + } + } + + fs.writeJSONSync(upgradeTestDbPath, db); + } + + function rewritePersistedMd5AsNull(): void { + const db = fs.readJSONSync(upgradeTestDbPath); + const blobCollection = db.collections.find( + (c: any) => c.name === "$BLOBS_COLLECTION$" + ); + + assert.ok(blobCollection !== undefined, "Blob collection should exist"); + + for (const doc of blobCollection.data) { + doc.properties.contentMD5 = null; + } + + fs.writeJSONSync(upgradeTestDbPath, db); + } + + /** + * Simulate the upgrade path + * This test verifies that persisted data can be loaded after version changes + */ + it("should upgrade without data loss", async () => { + // PHASE 1: Simulate old version behavior - create initial data + + const config1 = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + upgradeTestDbPath, + upgradeTestDbExtentPath, + [ + { + locationId: "test", + locationPath: upgradeBlobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const server1 = new BlobServer(config1); + await server1.start(); + + try { + const baseURL1 = `${server1.getHttpServerAddress()}/devstoreaccount1`; + const blobServiceClient1 = new BlobServiceClient( + baseURL1, + newPipeline( + new StorageSharedKeyCredential( + EMULATOR_ACCOUNT_NAME, + EMULATOR_ACCOUNT_KEY + ), + { retryOptions: { maxTries: 1 } } + ) + ); + + // Create container and blob + const containerClient: ContainerClient = + blobServiceClient1.getContainerClient(containerName); + await containerClient.create(); + + const blockBlobClient = containerClient.getBlockBlobClient(blobName); + await blockBlobClient.upload(blobContent, blobContent.length); + // Verify data was written + const downloadResponse = await blockBlobClient.download(0); + const downloadedStream = downloadResponse.readableStreamBody; + assert.ok( + downloadedStream !== undefined, + "Downloaded stream should exist" + ); + } finally { + await server1.close(); + // Don't clean - we want to preserve the persisted data for the upgrade test + // await server1.clean(); + } + + // Verify persistence files exist + assert.ok( + fs.existsSync(upgradeTestDbPath), + "Blob metadata database should be persisted" + ); + + // PHASE 2: Simulate upgrade - load existing data + + const config2 = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + upgradeTestDbPath, + upgradeTestDbExtentPath, + [ + { + locationId: "test", + locationPath: upgradeBlobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const server2 = new BlobServer(config2); + + try { + // This is where the bug would occur - loading legacy data with new GC manager + await server2.start(); + assert.ok( + server2.getStatus() === ServerStatus.Running, + "Server should be in Running state" + ); + + const baseURL2 = `${server2.getHttpServerAddress()}/devstoreaccount1`; + const blobServiceClient2 = new BlobServiceClient( + baseURL2, + newPipeline( + new StorageSharedKeyCredential( + EMULATOR_ACCOUNT_NAME, + EMULATOR_ACCOUNT_KEY + ), + { retryOptions: { maxTries: 1 } } + ) + ); + + // Verify existing data is accessible + const containerClient2: ContainerClient = + blobServiceClient2.getContainerClient(containerName); + const blockBlobClient2 = containerClient2.getBlockBlobClient(blobName); + + // Download the blob that was created in phase 1 + const downloadResponse2 = await blockBlobClient2.download(0); + assert.ok( + downloadResponse2.readableStreamBody !== undefined, + "Should be able to download persisted blob after upgrade" + ); + + // PHASE 3: Create new data with new version + const newBlobName = "new-blob.txt"; + const newBlobContent = "This data was created in the new version"; + + const newBlockBlobClient = + containerClient2.getBlockBlobClient(newBlobName); + await newBlockBlobClient.upload(newBlobContent, newBlobContent.length); + + // Verify new data + const newDownloadResponse = await newBlockBlobClient.download(0); + assert.ok( + newDownloadResponse.readableStreamBody !== undefined, + "New blob should be downloadable" + ); + + // Verify old data is still there + const oldDownloadResponse = await blockBlobClient2.download(0); + assert.ok( + oldDownloadResponse.readableStreamBody !== undefined, + "Old blob should still be accessible" + ); + + await server2.close(); + } finally { + try { + await server2.clean(); + } catch (err) { + console.log("Note: Cleanup had an issue, but that's ok for this test"); + } + } + }); + + /** + * Test that verifies the specific scenario from issue #2672: + * Multiple accounts with existing persisted data + */ + it("should handle startup with multiple existing accounts and containers", async () => { + const config3 = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + upgradeTestDbPath, + upgradeTestDbExtentPath, + [ + { + locationId: "test", + locationPath: upgradeBlobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const server3 = new BlobServer(config3); + await server3.start(); + + try { + const baseURL3 = `${server3.getHttpServerAddress()}/devstoreaccount1`; + const blobServiceClient3 = new BlobServiceClient( + baseURL3, + newPipeline( + new StorageSharedKeyCredential( + EMULATOR_ACCOUNT_NAME, + EMULATOR_ACCOUNT_KEY + ), + { retryOptions: { maxTries: 1 } } + ) + ); + + // Create multiple containers with blobs + const containers = ["container-1", "container-2", "container-3"]; + for (const containerName of containers) { + const containerClient = + blobServiceClient3.getContainerClient(containerName); + await containerClient.create(); + + // Add a blob to each container + for (let i = 0; i < 3; i++) { + const payload = `Content for ${containerName} blob ${i}`; + const blobClient = containerClient.getBlockBlobClient( + `blob-${i}.txt` + ); + await blobClient.upload(payload, Buffer.byteLength(payload)); + } + } + } finally { + await server3.close(); + } + + // Now restart and verify all data is still accessible + const config4 = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + upgradeTestDbPath, + upgradeTestDbExtentPath, + [ + { + locationId: "test", + locationPath: upgradeBlobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const server4 = new BlobServer(config4); + + try { + await server4.start(); + + const baseURL4 = `${server4.getHttpServerAddress()}/devstoreaccount1`; + const blobServiceClient4 = new BlobServiceClient( + baseURL4, + newPipeline( + new StorageSharedKeyCredential( + EMULATOR_ACCOUNT_NAME, + EMULATOR_ACCOUNT_KEY + ), + { retryOptions: { maxTries: 1 } } + ) + ); + + // Verify all containers and blobs are accessible + const containers = ["container-1", "container-2", "container-3"]; + for (const containerName of containers) { + const containerClient = + blobServiceClient4.getContainerClient(containerName); + + // List blobs to verify they're accessible + let blobCount = 0; + for await (const blob of containerClient.listBlobsFlat()) { + blobCount++; + assert.ok(blob.name !== undefined, "Blob should have a name"); + } + + assert.strictEqual( + blobCount, + 3, + `Container ${containerName} should have 3 blobs` + ); + } + + await server4.close(); + } finally { + try { + await server4.clean(); + } catch (err) { + console.log("Note: Cleanup had an issue, but that's ok for this test"); + } + } + }); + + it("should load persisted metadata across legacy contentMD5 serialization formats", async () => { + const shapes: Array<"buffer-json" | "numeric-object" | "plain-array"> = [ + "buffer-json", + "numeric-object", + "plain-array" + ]; + + for (const shape of shapes) { + allocatePaths(`shape_${shape}`); + const compatibilityContainer = `compat-${shape}`; + const compatibilityBlob = `blob-${shape}.txt`; + const compatibilityContent = `compatibility data for ${shape}`; + + const createConfig = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + upgradeTestDbPath, + upgradeTestDbExtentPath, + [ + { + locationId: "test", + locationPath: upgradeBlobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const createServer = new BlobServer(createConfig); + await createServer.start(); + + try { + const baseURL = `${createServer.getHttpServerAddress()}/devstoreaccount1`; + const blobServiceClient = new BlobServiceClient( + baseURL, + newPipeline( + new StorageSharedKeyCredential( + EMULATOR_ACCOUNT_NAME, + EMULATOR_ACCOUNT_KEY + ), + { retryOptions: { maxTries: 1 } } + ) + ); + + const containerClient = blobServiceClient.getContainerClient( + compatibilityContainer + ); + await containerClient.create(); + + const blobClient = + containerClient.getBlockBlobClient(compatibilityBlob); + await blobClient.upload( + compatibilityContent, + compatibilityContent.length + ); + } finally { + await createServer.close(); + } + + rewritePersistedMd5Shape(shape); + + const loadConfig = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + upgradeTestDbPath, + upgradeTestDbExtentPath, + [ + { + locationId: "test", + locationPath: upgradeBlobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const loadServer = new BlobServer(loadConfig); + await loadServer.start(); + + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.strictEqual( + loadServer.getStatus(), + ServerStatus.Running, + `Server should remain Running for md5 shape ${shape}` + ); + + const loadBaseURL = `${loadServer.getHttpServerAddress()}/devstoreaccount1`; + const loadClient = new BlobServiceClient( + loadBaseURL, + newPipeline( + new StorageSharedKeyCredential( + EMULATOR_ACCOUNT_NAME, + EMULATOR_ACCOUNT_KEY + ), + { retryOptions: { maxTries: 1 } } + ) + ); + + const containerClient = loadClient.getContainerClient( + compatibilityContainer + ); + const blobClient = + containerClient.getBlockBlobClient(compatibilityBlob); + const properties = await blobClient.getProperties(); + assert.ok( + properties.contentMD5 !== undefined, + `contentMD5 should be restored for md5 shape ${shape}` + ); + } finally { + await loadServer.close(); + await loadServer.clean(); + } + } + }); + + it("should handle persisted null contentMD5 without startup failure", async () => { + const compatibilityContainer = "compat-null-md5"; + const compatibilityBlob = "blob-null-md5.txt"; + const compatibilityContent = "compatibility data for null md5"; + + const createConfig = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + upgradeTestDbPath, + upgradeTestDbExtentPath, + [ + { + locationId: "test", + locationPath: upgradeBlobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const createServer = new BlobServer(createConfig); + await createServer.start(); + + try { + const baseURL = `${createServer.getHttpServerAddress()}/devstoreaccount1`; + const blobServiceClient = new BlobServiceClient( + baseURL, + newPipeline( + new StorageSharedKeyCredential( + EMULATOR_ACCOUNT_NAME, + EMULATOR_ACCOUNT_KEY + ), + { retryOptions: { maxTries: 1 } } + ) + ); + + const containerClient = blobServiceClient.getContainerClient( + compatibilityContainer + ); + await containerClient.create(); + + const blobClient = containerClient.getBlockBlobClient(compatibilityBlob); + await blobClient.upload( + compatibilityContent, + compatibilityContent.length + ); + } finally { + await createServer.close(); + } + + rewritePersistedMd5AsNull(); + + const loadConfig = new BlobConfiguration( + "127.0.0.1", + 0, + DEFAULT_BLOB_KEEP_ALIVE_TIMEOUT, + upgradeTestDbPath, + upgradeTestDbExtentPath, + [ + { + locationId: "test", + locationPath: upgradeBlobStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const loadServer = new BlobServer(loadConfig); + await loadServer.start(); + + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.strictEqual( + loadServer.getStatus(), + ServerStatus.Running, + "Server should remain Running for null contentMD5" + ); + + const loadBaseURL = `${loadServer.getHttpServerAddress()}/devstoreaccount1`; + const loadClient = new BlobServiceClient( + loadBaseURL, + newPipeline( + new StorageSharedKeyCredential( + EMULATOR_ACCOUNT_NAME, + EMULATOR_ACCOUNT_KEY + ), + { retryOptions: { maxTries: 1 } } + ) + ); + + const containerClient = loadClient.getContainerClient( + compatibilityContainer + ); + const blobClient = containerClient.getBlockBlobClient(compatibilityBlob); + const properties = await blobClient.getProperties(); + + assert.strictEqual( + properties.contentMD5, + undefined, + "Null persisted contentMD5 should be restored as undefined" + ); + } finally { + await loadServer.close(); + await loadServer.clean(); + } + }); +}); diff --git a/tests/queue/queueSas.test.ts b/tests/queue/queueSas.test.ts index 4052a99c2..e6caa0917 100644 --- a/tests/queue/queueSas.test.ts +++ b/tests/queue/queueSas.test.ts @@ -242,7 +242,44 @@ describe("Queue SAS test", () => { assert.ok(error); assert.deepEqual(error.statusCode, 403); - assert.deepEqual(error.code, 'AuthenticationFailed'); + assert.deepEqual(error.code, "AuthenticationFailed"); + }); + + it("generateAccountSASQueryParameters should reject duplicate SAS signature query @loki", async () => { + const tmr = new Date(); + tmr.setDate(tmr.getDate() + 1); + + const storageSharedKeyCredential = (serviceClient as any).credential; + + const sas = generateAccountSASQueryParameters( + { + expiresOn: tmr, + permissions: AccountSASPermissions.parse("rwdlacup"), + resourceTypes: AccountSASResourceTypes.parse("sco").toString(), + services: AccountSASServices.parse("btqf").toString(), + protocol: SASProtocol.HttpsAndHttp, + version: "2019-02-02" + }, + storageSharedKeyCredential as StorageSharedKeyCredential + ).toString(); + + const sasWithDuplicateSig = `${sas}&sig=duplicated-signature`; + const sasURL = `${serviceClient.url}?${sasWithDuplicateSig}`; + const serviceClientWithSAS = new QueueServiceClient( + sasURL, + newPipeline(new AnonymousCredential()) + ); + + let error; + try { + await serviceClientWithSAS.getProperties(); + } catch (err) { + error = err; + } + + assert.ok(error); + assert.deepStrictEqual(error.statusCode, 403); + assert.deepStrictEqual(error.code, "AuthenticationFailed"); }); it("Create queue should work with write (w) or create (c) permission in account SAS @loki", async () => { diff --git a/tests/queue/startupErrorRecovery.test.ts b/tests/queue/startupErrorRecovery.test.ts new file mode 100644 index 000000000..f6abd7215 --- /dev/null +++ b/tests/queue/startupErrorRecovery.test.ts @@ -0,0 +1,166 @@ +import * as assert from "assert"; +import * as fs from "fs-extra"; + +import QueueConfiguration from "../../src/queue/QueueConfiguration"; +import QueueServer from "../../src/queue/QueueServer"; +import { configLogger } from "../../src/common/Logger"; +import { DEFAULT_QUEUE_KEEP_ALIVE_TIMEOUT } from "../../src/queue/utils/constants"; +import { ServerStatus } from "../../src/common/ServerBase"; + +// Set true to enable debug log +configLogger(false); + +describe("Queue Server Startup Error Recovery - Issue #2672 @loki", () => { + const testDbPath = "__test_queue_startup_error_db__.json"; + const testDbExtentPath = "__test_queue_startup_error_db_extent__.json"; + const queueStoragePath = "__test_queue_startup_error_storage__"; + + async function startWithTimeout(server: QueueServer): Promise { + let timeout: ReturnType | undefined; + + try { + await Promise.race([ + server.start(), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error("Server startup timeout after 10 seconds")); + }, 10000); + + const nodeTimer = timeout as unknown as { unref?: () => void }; + if (typeof nodeTimer.unref === "function") { + nodeTimer.unref(); + } + }) + ]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } + } + + after(async () => { + // Clean up test artifacts + [testDbPath, testDbExtentPath, queueStoragePath].forEach((p) => { + if (fs.existsSync(p)) { + fs.removeSync(p); + } + }); + }); + + it("Queue: should start successfully when no persisted data exists (fresh start)", async () => { + // Clean paths before test + [testDbPath, testDbExtentPath, queueStoragePath].forEach((p) => { + if (fs.existsSync(p)) { + fs.removeSync(p); + } + }); + + const config = new QueueConfiguration( + "127.0.0.1", + 0, + DEFAULT_QUEUE_KEEP_ALIVE_TIMEOUT, + testDbPath, + testDbExtentPath, + [ + { + locationId: "test", + locationPath: queueStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const server = new QueueServer(config); + + try { + // Should start without errors + await server.start(); + assert.strictEqual(server.getStatus(), ServerStatus.Running); + + await server.close(); + assert.strictEqual(server.getStatus(), ServerStatus.Closed); + } finally { + await server.clean(); + } + }); + + it("Queue: should handle GC startup errors gracefully without throwing 'Cannot close server in status Starting'", async () => { + // Clean paths before test + [testDbPath, testDbExtentPath, queueStoragePath].forEach((p) => { + if (fs.existsSync(p)) { + fs.removeSync(p); + } + }); + + // Create a corrupted metadata file to simulate legacy data + const corruptedMetadata = { + schemaVersion: "3.35.0", + data: { + collections: [ + { + name: "queues", + data: [ + { + name: "test-queue", + meta: { revision: 0, created: 1600000000000, version: 0 } + } + ] + } + ] + } + }; + + fs.writeFileSync(testDbPath, JSON.stringify(corruptedMetadata, null, 2)); + + const config = new QueueConfiguration( + "127.0.0.1", + 0, + DEFAULT_QUEUE_KEEP_ALIVE_TIMEOUT, + testDbPath, + testDbExtentPath, + [ + { + locationId: "test", + locationPath: queueStoragePath, + maxConcurrency: 10 + } + ], + false + ); + + const server = new QueueServer(config); + + try { + await startWithTimeout(server); + + assert.notStrictEqual( + server.getStatus(), + ServerStatus.Starting, + "Queue server should not remain in Starting state after startup completes" + ); + + if (server.getStatus() === ServerStatus.Running) { + await server.close(); + } + } catch (err) { + if (!(err instanceof Error)) { + throw err; + } + + if (err.message.includes("Cannot close server in status Starting")) { + assert.fail(`Bug not fixed in Queue: ${err.message}`); + } + + // Do not swallow startup failures such as timeout/hang. + throw err; + } finally { + try { + await server.clean(); + } catch { + // Cleanup may fail if startup failed, that's ok + } + } + }); +}); diff --git a/tests/table/auth/sas.test.ts b/tests/table/auth/sas.test.ts index 952e1ae5d..13808c053 100644 --- a/tests/table/auth/sas.test.ts +++ b/tests/table/auth/sas.test.ts @@ -707,4 +707,57 @@ describe("Shared Access Signature (SAS) authentication", () => { stored = await adminClient.getEntity("part1", "row5"); assert.strictEqual(stored.myValue, "value2"); }); + + it("12. duplicate SAS signature query should fail authentication @loki", async () => { + const tableName: string = getUniqueName("sas12"); + + const conn = createConnectionStringForTest(testLocalAzuriteInstance); + const accountName = /AccountName=([^;]*)/.exec(conn)![1]; + const accountKey = /AccountKey=([^;]*)/.exec(conn)![1]; + const baseUrl = getBaseUrlForTest(); + + const adminClient = TableClient.fromConnectionString(conn, tableName, { + allowInsecureConnection: testLocalAzuriteInstance + }); + + await adminClient.createTable(); + + const now = Date.now(); + const expiry = new Date(now + 5 * 60 * 1000).toISOString(); + + const sas = generateTableSasToken({ + accountName, + accountKey, + tableName, + permissions: "a", + expiry + }); + + const duplicateSigToken = `${sas}&sig=duplicated-signature`; + const sasClient = new TableClient( + baseUrl, + tableName, + new AzureSASCredential(duplicateSigToken), + { + allowInsecureConnection: testLocalAzuriteInstance + } + ); + + let error; + try { + await sasClient.createEntity({ + partitionKey: "part1", + rowKey: "row-duplicate-sig", + myValue: "value1" + }); + } catch (err: any) { + error = err; + } + + assert.ok(error); + assert.strictEqual(error.statusCode, 403); + if (error.code) { + assert.strictEqual(error.code, "AuthenticationFailed"); + } + }); }); From dfee271094d58b47c7bd0add1c4d30f2c7681611 Mon Sep 17 00:00:00 2001 From: Akanksha Jain Date: Thu, 30 Jul 2026 18:38:36 +0530 Subject: [PATCH 7/7] Add .npmrc configuration for npm registry --- .npmrc | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..2004d4145 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +registry=https://registry.npmjs.org/ +always-auth=true +//registry.npmjs.org/:_authToken=${NPM_TOKEN}