From 6c80ef8330927c6927cff5899d17cf5ef36f32bc Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 5 May 2026 23:08:09 +0800 Subject: [PATCH] fix(installer): address release asset review followups --- scripts/build-installation-assets.js | 49 ++-------- scripts/build-standalone-release.js | 56 ++++------- scripts/create-standalone-package.js | 52 ++++------ .../installation/install-qwen-with-source.bat | 78 ++++++++++++--- scripts/release-script-utils.js | 52 ++++++++++ scripts/tests/install-script.test.js | 98 ++++++++++++++++++- 6 files changed, 260 insertions(+), 125 deletions(-) create mode 100644 scripts/release-script-utils.js diff --git a/scripts/build-installation-assets.js b/scripts/build-installation-assets.js index 48c5aa843..12d2552b7 100644 --- a/scripts/build-installation-assets.js +++ b/scripts/build-installation-assets.js @@ -7,18 +7,23 @@ */ import fs from 'node:fs'; -import crypto from 'node:crypto'; import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import { writeSha256Sums } from './create-standalone-package.js'; import { INSTALLATION_ASSETS } from './release-asset-config.js'; +import { + fail, + isMainModule, + parseSha256Sums, + readOptionValue, + sha256File, +} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); -if (isMainModule()) { +if (isMainModule(import.meta.url)) { try { const args = parseArgs(process.argv.slice(2)); if (args.help) { @@ -37,10 +42,6 @@ if (isMainModule()) { } } -function isMainModule() { - return process.argv[1] && path.resolve(process.argv[1]) === __filename; -} - async function buildInstallationAssets(outDir, options = {}) { const { assets = INSTALLATION_ASSETS, root = rootDir, version } = options; fs.mkdirSync(outDir, { recursive: true }); @@ -139,28 +140,6 @@ async function assertInstallationAssetChecksums( } } -function parseSha256Sums(content) { - const checksums = new Map(); - for (const line of content.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - - const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); - if (match) { - checksums.set(match[2], match[1].toLowerCase()); - } - } - return checksums; -} - -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - function parseArgs(argv) { const args = { help: false, @@ -192,14 +171,6 @@ function parseArgs(argv) { return args; } -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - function printUsage() { console.log(` Usage: @@ -212,10 +183,6 @@ Options: `); } -function fail(message) { - throw new Error(`ERROR: ${message}`); -} - export { assertInstallationAssetChecksums, buildInstallationAssets, diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index 4ab880ce0..01bc13f61 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -7,15 +7,21 @@ */ import { execFileSync } from 'node:child_process'; -import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { writeSha256Sums } from './create-standalone-package.js'; +import { TARGETS, writeSha256Sums } from './create-standalone-package.js'; import { isStandaloneArchiveName } from './release-asset-config.js'; +import { + fail, + isMainModule, + parseSha256Sums, + readOptionValue, + sha256File, +} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -46,7 +52,7 @@ const RELEASE_TARGETS = [ ]; const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length; -if (isMainModule()) { +if (isMainModule(import.meta.url)) { try { await main(); } catch (error) { @@ -102,10 +108,6 @@ async function main() { } } -function isMainModule() { - return process.argv[1] && path.resolve(process.argv[1]) === __filename; -} - async function packageTarget({ qwenTarget, nodeTarget, @@ -161,14 +163,7 @@ async function downloadFile(url, destination) { } function parseChecksums(content) { - const checksums = new Map(); - for (const line of content.split(/\r?\n/)) { - const [hash, fileName] = line.trim().split(/\s+/, 2); - if (hash && fileName) { - checksums.set(fileName.replace(/^\*/, ''), hash); - } - } - return checksums; + return parseSha256Sums(content); } async function verifyNodeArchive(archivePath, archiveName, checksums) { @@ -185,12 +180,6 @@ async function verifyNodeArchive(archivePath, archiveName, checksums) { console.log(`Verified Node.js runtime checksum for ${archiveName}`); } -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - function assertStandaloneOutput(outDir) { const checksumPath = path.join(outDir, 'SHA256SUMS'); if (!fs.existsSync(checksumPath)) { @@ -205,9 +194,8 @@ function assertStandaloneOutput(outDir) { .filter(Boolean) .filter(isStandaloneArchiveName) .sort(); - const expectedArchiveNames = RELEASE_TARGETS.map( - ({ qwenTarget }) => - `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, + const expectedArchiveNames = RELEASE_TARGETS.map(({ qwenTarget }) => + standaloneArchiveName(qwenTarget), ).sort(); const missing = expectedArchiveNames.filter( (archiveName) => !archiveNames.includes(archiveName), @@ -236,6 +224,14 @@ function assertStandaloneOutput(outDir) { console.log(`Verified ${archiveNames.length} standalone release checksums.`); } +function standaloneArchiveName(qwenTarget) { + const targetConfig = TARGETS.get(qwenTarget); + if (!targetConfig) { + fail(`No standalone package target config found for ${qwenTarget}`); + } + return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`; +} + function parseArgs(argv) { const args = { help: false, @@ -276,14 +272,6 @@ function parseArgs(argv) { return args; } -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - function printUsage() { console.log(` Usage: @@ -297,8 +285,4 @@ Options: `); } -function fail(message) { - throw new Error(`ERROR: ${message}`); -} - export { assertStandaloneOutput, parseChecksums, RELEASE_TARGETS }; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index a5872990d..9d49f0e50 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -7,13 +7,17 @@ */ import { execFileSync } from 'node:child_process'; -import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import { isReleaseChecksumAsset } from './release-asset-config.js'; +import { + fail, + isMainModule, + readOptionValue, + sha256File, +} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -53,7 +57,7 @@ const DIST_ALLOWED_ENTRY_PATTERNS = [ ]; const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; -if (isMainModule()) { +if (isMainModule(import.meta.url)) { try { await main(); } catch (error) { @@ -132,10 +136,6 @@ async function main() { } } -function isMainModule() { - return process.argv[1] && path.resolve(process.argv[1]) === __filename; -} - function parseArgs(argv) { const args = { help: false, @@ -180,14 +180,6 @@ function parseArgs(argv) { return args; } -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - function printUsage() { console.log(`Qwen Code standalone package builder @@ -552,6 +544,11 @@ function createZipArchive(outputPath, cwd) { run('zip', ['-qr', outputPath, 'qwen-code'], { cwd }); } +/** + * Rebuild SHA256SUMS from scratch by scanning outDir for all release checksum + * assets. This overwrites any existing SHA256SUMS, so callers must ensure all + * desired release assets are present in outDir before calling. + */ async function writeSha256Sums(outDir) { const entries = fs.readdirSync(outDir).filter(isReleaseChecksumAsset).sort(); @@ -561,22 +558,17 @@ async function writeSha256Sums(outDir) { ); } - const lines = []; - for (const entry of entries) { - const filePath = path.join(outDir, entry); - const hash = await sha256File(filePath); - lines.push(`${hash} ${entry}`); - } + const lines = await Promise.all( + entries.map(async (entry) => { + const filePath = path.join(outDir, entry); + const hash = await sha256File(filePath); + return `${hash} ${entry}`; + }), + ); fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - function run(command, args, options = {}) { try { execFileSync(command, args, { @@ -592,8 +584,4 @@ function run(command, args, options = {}) { } } -function fail(message) { - throw new Error(`Error: ${message}`); -} - -export { writeSha256Sums }; +export { TARGETS, writeSha256Sums }; diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index c8ce8cf06..93568f603 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -481,8 +481,8 @@ if not "!ARCHIVE_PATH!"=="" ( ) ) - set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" - mkdir "!TEMP_DIR!" >nul 2>&1 + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" echo INFO: Downloading !ARCHIVE_URL! @@ -495,8 +495,8 @@ if not "!ARCHIVE_PATH!"=="" ( ) if "!TEMP_DIR!"=="" ( - set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" - mkdir "!TEMP_DIR!" >nul 2>&1 + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 ) REM Verify integrity before extraction or changing the install directory. @@ -509,6 +509,11 @@ if !ERRORLEVEL! NEQ 0 ( REM Extract into a temporary directory, then validate required entry points. set "EXTRACT_DIR=!TEMP_DIR!\extract" mkdir "!EXTRACT_DIR!" >nul 2>&1 +call :ValidateArchiveContents "!ARCHIVE_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" @@ -564,8 +569,22 @@ if !ERRORLEVEL! NEQ 0 ( exit /b 1 ) -if exist "!NEW_INSTALL_DIR!" rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 -if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 +if exist "!NEW_INSTALL_DIR!" ( + rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale staging directory: !NEW_INSTALL_DIR!. + exit /b 1 + ) +) +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale backup directory: !OLD_INSTALL_DIR!. + exit /b 1 + ) +) move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 @@ -583,7 +602,7 @@ if exist "!INSTALL_DIR!" ( ) move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul if !ERRORLEVEL! NEQ 0 ( - if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + call :RestoreOldInstall if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. exit /b 1 @@ -594,8 +613,8 @@ echo @echo off echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* ) > "!INSTALL_BIN_DIR!\qwen.cmd.new" if !ERRORLEVEL! NEQ 0 ( - if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 - if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. exit /b 1 @@ -603,14 +622,17 @@ if !ERRORLEVEL! NEQ 0 ( move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul if !ERRORLEVEL! NEQ 0 ( if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 - if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 - if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. exit /b 1 ) -if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove old install backup: !OLD_INSTALL_DIR! +) set "PATH=!INSTALL_BIN_DIR!;!PATH!" call :CreateSourceJson @@ -620,6 +642,38 @@ echo SUCCESS: Qwen Code standalone archive installed successfully. echo INFO: Installed to !INSTALL_DIR! exit /b 0 +:CreateTempDir +set "TEMP_DIR=" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = Join-Path $env:TEMP ('qwen-code-install-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType Directory -Path $dir -ErrorAction Stop | Out-Null; [Console]::Write($dir)"`) do set "TEMP_DIR=%%I" +if "!TEMP_DIR!"=="" ( + echo ERROR: Failed to create a temporary directory. + exit /b 1 +) +exit /b 0 + +:ValidateArchiveContents +set "QWEN_ARCHIVE_FILE=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); try { foreach ($entry in $archive.Entries) { $name = $entry.FullName; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name.StartsWith('\') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)' -or $name.Contains('\')) { Write-Error ('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } finally { $archive.Dispose() }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_ARCHIVE_FILE=" +if %PS_STATUS% NEQ 0 echo ERROR: Archive contains unsafe path entries. +exit /b %PS_STATUS% + +:RemoveInstalledDirWithWarning +if not exist "!INSTALL_DIR!" exit /b 0 +rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 +if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove failed install directory: !INSTALL_DIR! +exit /b 0 + +:RestoreOldInstall +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Failed to restore previous install from !OLD_INSTALL_DIR! to !INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + :RejectArchiveLinks set "QWEN_EXTRACT_DIR=%~1" powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js new file mode 100644 index 000000000..656e50a03 --- /dev/null +++ b/scripts/release-script-utils.js @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; + +function isMainModule(importMetaUrl) { + const filename = fileURLToPath(importMetaUrl); + return process.argv[1] && path.resolve(process.argv[1]) === filename; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function parseSha256Sums(content) { + const checksums = new Map(); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); + if (match) { + checksums.set(match[2], match[1].toLowerCase()); + } + } + return checksums; +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + +export { fail, isMainModule, parseSha256Sums, readOptionValue, sha256File }; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 98a96e4c0..9b76da500 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -28,6 +28,9 @@ const readScript = (path) => readFileSync(path, 'utf8'); const standaloneReleaseScriptUrl = pathToFileURL( path.resolve('scripts/build-standalone-release.js'), ).href; +const standalonePackageScriptUrl = pathToFileURL( + path.resolve('scripts/create-standalone-package.js'), +).href; const installationAssetsScriptUrl = pathToFileURL( path.resolve('scripts/build-installation-assets.js'), ).href; @@ -167,6 +170,12 @@ describe('installation scripts', () => { expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); expect(script).not.toContain('certutil -hashfile'); expect(script).toContain('qwen-code-win-x64.zip'); + expect(script).toContain(':ValidateArchiveContents'); + expect(script).toContain('Archive contains unsafe path entries'); + expect(script).toContain('System.IO.Compression.FileSystem'); + expect(script).toContain('[IO.Compression.ZipFile]::OpenRead'); + expect(script).toContain('[IO.Path]::GetRandomFileName()'); + expect(script).not.toContain('qwen-code-install-%RANDOM%%RANDOM%'); expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); @@ -199,6 +208,8 @@ describe('installation scripts', () => { ); expect(script).toContain('qwen-code\\node\\node.exe'); expect(script).toContain('Archive contains symlinks or reparse points'); + expect(script).toContain('WARNING: Failed to restore previous install'); + expect(script).toContain('WARNING: Failed to remove failed install'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); }); @@ -221,6 +232,7 @@ describe('standalone release packaging', () => { expect(existsSync('scripts/build-standalone-release.js')).toBe(true); expect(existsSync('scripts/build-installation-assets.js')).toBe(true); expect(existsSync('scripts/release-asset-config.js')).toBe(true); + expect(existsSync('scripts/release-script-utils.js')).toBe(true); const packageScript = readScript('scripts/create-standalone-package.js'); expect(packageScript).toContain('Copyright 2025 Qwen Team'); @@ -235,12 +247,16 @@ describe('standalone release packaging', () => { expect(packageScript).toContain('refusing to write empty SHA256SUMS'); expect(packageScript).toContain('--skip-checksums'); expect(packageScript).toContain('dereference: true'); - expect(packageScript).toContain('fs.createReadStream'); expect(packageScript).toContain('Expand-Archive'); expect(packageScript).toContain('Compress-Archive'); + expect(packageScript).toContain('Rebuild SHA256SUMS from scratch'); + expect(packageScript).toContain('Promise.all('); expect(packageScript).toContain( "import { isReleaseChecksumAsset } from './release-asset-config.js';", ); + expect(packageScript).toContain( + "import {\n fail,\n isMainModule,\n readOptionValue,\n sha256File,\n} from './release-script-utils.js';", + ); const releaseScript = readScript('scripts/build-standalone-release.js'); expect(releaseScript).toContain('Copyright 2025 Qwen Team'); @@ -251,9 +267,9 @@ describe('standalone release packaging', () => { 'EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length', ); expect(releaseScript).toContain('nodeArchiveExtension'); - expect(releaseScript).toContain('fs.createReadStream'); expect(releaseScript).toContain('expectedArchiveNames'); - expect(releaseScript).toContain('qwen-code-${qwenTarget}'); + expect(releaseScript).toContain('standaloneArchiveName(qwenTarget)'); + expect(releaseScript).toContain('TARGETS.get(qwenTarget)'); expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); @@ -269,6 +285,9 @@ describe('standalone release packaging', () => { expect(installationAssetsScript).toContain( 'assertInstallationAssetChecksums(outDir, assets)', ); + expect(installationAssetsScript).toContain( + "from './release-script-utils.js'", + ); const releaseAssetConfig = readScript('scripts/release-asset-config.js'); expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team'); @@ -279,6 +298,13 @@ describe('standalone release packaging', () => { expect(releaseAssetConfig).toContain('install-qwen.bat'); expect(releaseAssetConfig).toContain('isStandaloneArchiveName'); expect(releaseAssetConfig).toContain('isReleaseChecksumAsset'); + + const releaseScriptUtils = readScript('scripts/release-script-utils.js'); + expect(releaseScriptUtils).toContain('Copyright 2025 Qwen Team'); + expect(releaseScriptUtils).toContain('function parseSha256Sums'); + expect(releaseScriptUtils).toContain('async function sha256File'); + expect(releaseScriptUtils).toContain('function readOptionValue'); + expect(releaseScriptUtils).toContain('function isMainModule'); }); it('loads the standalone release packaging helper', () => { @@ -363,11 +389,12 @@ describe('standalone release packaging', () => { const { assertStandaloneOutput, RELEASE_TARGETS } = await import( standaloneReleaseScriptUrl ); + const { TARGETS } = await import(standalonePackageScriptUrl); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-test-')); try { const lines = RELEASE_TARGETS.map(({ qwenTarget }) => { - const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'; + const extension = TARGETS.get(qwenTarget).outputExtension; return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; }); writeFileSync( @@ -1092,6 +1119,28 @@ describe('Windows installer end-to-end', () => { } }); + itOnWindows( + 'rejects standalone archives containing path traversal entries', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createWindowsTraversalStandaloneArchive(tmpDir); + + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive contains unsafe path/); + expect(existsSync(path.join(tmpDir, 'qwen-slip'))).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + itOnWindows('rejects unsafe environment-derived install paths', () => { const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -1237,6 +1286,47 @@ function createFakeWindowsStandaloneArchive(tmpDir) { return archive; } +function createWindowsTraversalStandaloneArchive(tmpDir) { + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + + const archive = path.join(outDir, 'qwen-code-win-x64.zip'); + execFileSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + [ + "$ErrorActionPreference = 'Stop'", + 'Add-Type -AssemblyName System.IO.Compression.FileSystem', + 'function Add-ZipEntry($zip, $name, $content) {', + ' $entry = $zip.CreateEntry($name)', + ' $writer = [IO.StreamWriter]::new($entry.Open())', + ' try { $writer.Write($content) } finally { $writer.Dispose() }', + '}', + '$zip = [IO.Compression.ZipFile]::Open($env:QWEN_TEST_ZIP_ARCHIVE, [IO.Compression.ZipArchiveMode]::Create)', + 'try {', + " Add-ZipEntry $zip '../qwen-slip' 'path traversal'", + " Add-ZipEntry $zip 'qwen-code/bin/qwen.cmd' '@echo off`r`necho 0.0.0-smoke`r`n'", + " Add-ZipEntry $zip 'qwen-code/node/node.exe' 'fake node.exe'", + ' Add-ZipEntry $zip \'qwen-code/manifest.json\' \'{"name":"@qwen-code/qwen-code"}\'', + '} finally { $zip.Dispose() }', + ].join('; '), + ], + { + env: { + ...process.env, + QWEN_TEST_ZIP_ARCHIVE: archive, + }, + stdio: 'ignore', + }, + ); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { execFileSync(