mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 16:28:28 +00:00
fix(installer): address release asset review followups
This commit is contained in:
parent
5983d116f3
commit
6c80ef8330
6 changed files with 260 additions and 125 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 }"
|
||||
|
|
|
|||
52
scripts/release-script-utils.js
Normal file
52
scripts/release-script-utils.js
Normal 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 };
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue