fix(installer): address release asset review followups

This commit is contained in:
yiliang114 2026-05-05 23:08:09 +08:00
parent 5983d116f3
commit 6c80ef8330
6 changed files with 260 additions and 125 deletions

View file

@ -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,

View file

@ -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 };

View file

@ -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 };

View file

@ -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 }"

View file

@ -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 };

View file

@ -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(