fix(installer): harden standalone review fixes

This commit is contained in:
yiliang114 2026-05-04 17:38:07 +08:00
parent e7e3f9077d
commit fee51d1d91
6 changed files with 496 additions and 86 deletions

View file

@ -14,6 +14,7 @@ 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';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -44,11 +45,13 @@ const RELEASE_TARGETS = [
];
const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length;
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
if (isMainModule()) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}
async function main() {
@ -89,12 +92,17 @@ async function main() {
});
}
await writeSha256Sums(outDir);
assertStandaloneOutput(outDir);
} finally {
fs.rmSync(runtimeDir, { recursive: true, force: true });
}
}
function isMainModule() {
return process.argv[1] && path.resolve(process.argv[1]) === __filename;
}
async function packageTarget({
qwenTarget,
nodeTarget,
@ -120,6 +128,7 @@ async function packageTarget({
archivePath,
'--out-dir',
outDir,
'--skip-checksums',
];
if (releaseVersion) {
args.push('--version', releaseVersion);
@ -287,3 +296,5 @@ Options:
function fail(message) {
throw new Error(`ERROR: ${message}`);
}
export { assertStandaloneOutput, parseChecksums, RELEASE_TARGETS };

View file

@ -37,13 +37,28 @@ const TARGETS = new Map([
]);
const DIST_REQUIRED_PATHS = ['cli.js', 'vendor', 'bundled/qc-helper/docs'];
const DIST_ALLOWED_ENTRIES = new Set([
'cli.js',
'vendor',
'bundled',
'package.json',
'README.md',
'LICENSE',
'locales',
'examples',
]);
const DIST_ALLOWED_ENTRY_PATTERNS = [
/^sandbox-macos-(permissive|restrictive)-(open|closed|proxied)\.sb$/,
];
const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE'];
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
if (isMainModule()) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}
async function main() {
@ -85,7 +100,7 @@ async function main() {
fs.mkdirSync(packageRoot, { recursive: true });
fs.mkdirSync(runtimeExtractDir, { recursive: true });
copyRuntimeAssets(packageRoot);
copyRuntimeAssets(packageRoot, outDir);
extractNodeArchive(nodeArchive, runtimeExtractDir);
const nodeDir = path.join(packageRoot, 'node');
copyExtractedNode(runtimeExtractDir, nodeDir);
@ -101,22 +116,31 @@ async function main() {
fs.rmSync(outputPath, { force: true });
}
createArchive(targetConfig.outputExtension, outputPath, tempRoot);
await writeSha256Sums(outDir);
if (!args.skipChecksums) {
await writeSha256Sums(outDir);
}
console.log(`Created ${path.relative(rootDir, outputPath)}`);
console.log(
`Updated ${path.relative(rootDir, path.join(outDir, 'SHA256SUMS'))}`,
);
if (!args.skipChecksums) {
console.log(
`Updated ${path.relative(rootDir, path.join(outDir, 'SHA256SUMS'))}`,
);
}
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
function isMainModule() {
return process.argv[1] && path.resolve(process.argv[1]) === __filename;
}
function parseArgs(argv) {
const args = {
help: false,
outDir: undefined,
nodeArchive: undefined,
skipChecksums: false,
target: undefined,
version: undefined,
};
@ -144,6 +168,9 @@ function parseArgs(argv) {
args.version = readOptionValue(argv, index, arg);
index += 1;
break;
case '--skip-checksums':
args.skipChecksums = true;
break;
default:
fail(`Unknown option: ${arg}`);
}
@ -171,6 +198,7 @@ Options:
--node-archive PATH Downloaded Node.js runtime archive.
--out-dir DIR Output directory. Defaults to dist/standalone.
--version VERSION Qwen Code version. Defaults to package.json version.
--skip-checksums Do not update SHA256SUMS. Used by release packaging.
-h, --help Show this help message.`);
}
@ -200,14 +228,18 @@ function readPackageVersion() {
return packageJson.version;
}
function copyRuntimeAssets(packageRoot) {
function copyRuntimeAssets(packageRoot, outDir) {
const libDir = path.join(packageRoot, 'lib');
const skippedDistEntry = topLevelDistEntryForPath(outDir);
fs.mkdirSync(libDir, { recursive: true });
for (const entry of fs.readdirSync(distDir)) {
if (entry === 'standalone') {
if (entry === skippedDistEntry || entry === '.DS_Store') {
continue;
}
if (!isAllowedDistEntry(entry)) {
fail(`Unexpected dist asset: ${path.join(distDir, entry)}`);
}
fs.cpSync(path.join(distDir, entry), path.join(libDir, entry), {
recursive: true,
dereference: true,
@ -223,15 +255,30 @@ function copyRuntimeAssets(packageRoot) {
);
}
const distPackageJson = path.join(distDir, 'package.json');
if (fs.existsSync(distPackageJson)) {
fs.copyFileSync(distPackageJson, path.join(packageRoot, 'package.json'));
} else {
fs.copyFileSync(
path.join(rootDir, 'package.json'),
path.join(packageRoot, 'package.json'),
);
fs.copyFileSync(
path.join(rootDir, 'package.json'),
path.join(packageRoot, 'package.json'),
);
}
function topLevelDistEntryForPath(candidatePath) {
const relative = path.relative(distDir, candidatePath);
if (
relative === '' ||
relative.startsWith('..') ||
path.isAbsolute(relative)
) {
return undefined;
}
return relative.split(path.sep)[0];
}
function isAllowedDistEntry(entry) {
return (
DIST_ALLOWED_ENTRIES.has(entry) ||
DIST_ALLOWED_ENTRY_PATTERNS.some((pattern) => pattern.test(entry))
);
}
function extractNodeArchive(nodeArchive, extractDir) {
@ -295,64 +342,95 @@ function copyExtractedNode(extractDir, nodeDir) {
// Official Unix Node.js archives include internal npm/npx symlinks.
// The installer rejects symlinks in final archives, so keep safe internal
// targets by copying their referents and reject any symlink that escapes.
assertSymlinksStayInside(sourceRoot);
copyDereferenced(sourceRoot, nodeDir);
assertNoSymlinks(nodeDir, 'Copied Node.js runtime still contains symlinks.');
// targets by copying their referents during a single checked traversal.
copyNodeRuntimeEntry(sourceRoot, nodeDir, {
realRoot: fs.realpathSync(sourceRoot),
sourceRoot,
activeDirectories: new Set(),
});
}
function copyDereferenced(source, destination) {
const stat = fs.statSync(source);
function copyNodeRuntimeEntry(source, destination, state) {
const lstat = fs.lstatSync(source);
if (stat.isDirectory()) {
fs.mkdirSync(destination, { recursive: true });
fs.chmodSync(destination, stat.mode);
for (const entry of fs.readdirSync(source)) {
copyDereferenced(path.join(source, entry), path.join(destination, entry));
}
if (lstat.isSymbolicLink()) {
copyNodeRuntimeEntry(
resolveRuntimeSymlink(source, state),
destination,
state,
);
return;
}
if (stat.isFile()) {
if (lstat.isDirectory()) {
const realSource = fs.realpathSync(source);
if (state.activeDirectories.has(realSource)) {
fail(
`Node.js runtime contains a symlink cycle at ${displayRuntimePath(
state,
source,
)}`,
);
}
state.activeDirectories.add(realSource);
fs.mkdirSync(destination, { recursive: true });
fs.chmodSync(destination, lstat.mode);
for (const entry of fs.readdirSync(source)) {
copyNodeRuntimeEntry(
path.join(source, entry),
path.join(destination, entry),
state,
);
}
state.activeDirectories.delete(realSource);
return;
}
if (lstat.isFile()) {
fs.copyFileSync(source, destination);
fs.chmodSync(destination, stat.mode);
fs.chmodSync(destination, lstat.mode);
return;
}
fail(`Unsupported Node.js runtime entry type: ${source}`);
}
function assertSymlinksStayInside(root) {
const realRoot = fs.realpathSync(root);
for (const entry of walkDirectory(root)) {
if (!fs.lstatSync(entry).isSymbolicLink()) {
continue;
}
const target = fs.readlinkSync(entry);
const resolvedTarget = path.resolve(path.dirname(entry), target);
let realTarget;
try {
realTarget = fs.realpathSync(resolvedTarget);
} catch {
fail(
`Node.js runtime symlink points to a missing target: ${path.relative(
root,
entry,
)} -> ${target}`,
);
}
if (!isPathInside(realRoot, realTarget)) {
fail(
`Node.js runtime symlink escapes the archive: ${path.relative(
root,
entry,
)} -> ${target}`,
);
}
function resolveRuntimeSymlink(source, state) {
const target = fs.readlinkSync(source);
const resolvedTarget = path.resolve(path.dirname(source), target);
let realTarget;
try {
realTarget = fs.realpathSync(resolvedTarget);
} catch (error) {
const errorCode =
error && typeof error === 'object' && 'code' in error
? error.code
: undefined;
const reason =
errorCode === 'ELOOP' ? 'a symlink cycle' : 'a missing target';
fail(
`Node.js runtime symlink points to ${reason}: ${displayRuntimePath(
state,
source,
)} -> ${target}`,
);
}
if (!isPathInside(state.realRoot, realTarget)) {
fail(
`Node.js runtime symlink escapes the archive: ${displayRuntimePath(
state,
source,
)} -> ${target}`,
);
}
return resolvedTarget;
}
function displayRuntimePath(state, source) {
return path.relative(state.sourceRoot, source) || '.';
}
function assertNoSymlinks(root, message) {
@ -523,3 +601,5 @@ function run(command, args, options = {}) {
function fail(message) {
throw new Error(`Error: ${message}`);
}
export { writeSha256Sums };

View file

@ -170,7 +170,11 @@ if !STANDALONE_STATUS! EQU 0 (
if !STANDALONE_STATUS! EQU 2 (
echo WARNING: Falling back to npm installation.
call :InstallNpm
if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL!
if !ERRORLEVEL! NEQ 0 (
echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed.
echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm.
exit /b !ERRORLEVEL!
)
call :PrintFinalInstructions ""
endlocal
exit /b 0
@ -246,16 +250,19 @@ if "!INSTALL_BIN_DIR!"=="" (
exit /b 1
)
if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok
if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok
if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok
echo ERROR: QWEN_INSTALL_ROOT must be an absolute path.
exit /b 1
:validate_install_base_ok
if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok
if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok
if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok
echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path.
exit /b 1
:validate_install_dir_ok
if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok
if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok
if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok
echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path.
exit /b 1

View file

@ -343,7 +343,7 @@ require_node() {
if ! command_exists node; then
log_error "Node.js was not found."
print_node_help
exit 1
return 1
fi
local node_version
@ -354,13 +354,13 @@ require_node() {
if [[ -z "${node_major}" ]] || ! [[ "${node_major}" =~ ^[0-9]+$ ]]; then
log_error "Unable to determine Node.js version."
print_node_help
exit 1
return 1
fi
if [[ "${node_major}" -lt 20 ]]; then
log_error "Node.js ${node_version:-unknown} is installed, but Node.js 20 or newer is required."
print_node_help
exit 1
return 1
fi
log_success "Node.js ${node_version} detected."
@ -377,7 +377,7 @@ require_npm() {
echo "Please install Node.js with npm included, then rerun this installer."
echo "Download Node.js from https://nodejs.org/ if your package manager"
echo "installed Node without npm."
exit 1
return 1
}
get_npm_global_bin() {
@ -576,7 +576,16 @@ verify_checksum() {
fi
local expected
expected=$(grep -E "(^|[[:space:]])[*]?${archive_name}$" "${checksum_file}" | awk '{print $1}' | head -n 1)
expected=$(awk -v archive_name="${archive_name}" '
{
name = $2
sub(/^\*/, "", name)
if (name == archive_name) {
print $1
exit
}
}
' "${checksum_file}")
if [[ -z "${expected}" ]]; then
rm -f "${temp_checksum}"
log_error "Checksum entry for ${archive_name} not found."
@ -600,11 +609,60 @@ verify_checksum() {
log_success "Checksum verified for ${archive_name}."
}
validate_archive_entry_path() {
local entry="$1"
while [[ "${entry}" == ./* ]]; do
entry="${entry#./}"
done
case "${entry}" in
""|/*|..|../*|*/..|*/../*|*\\*)
log_error "Archive contains unsafe path: ${entry:-<empty>}"
return 1
;;
esac
}
validate_archive_contents() {
local archive_path="$1"
local entries
local entry
case "${archive_path}" in
*.zip)
if ! command_exists unzip; then
log_error "unzip is required to inspect ${archive_path}."
return 1
fi
if ! entries=$(unzip -Z1 "${archive_path}"); then
log_error "Failed to inspect archive entries: ${archive_path}"
return 1
fi
;;
*.tar.gz|*.tgz|*.tar.xz)
if ! entries=$(tar -tf "${archive_path}"); then
log_error "Failed to inspect archive entries: ${archive_path}"
return 1
fi
;;
*)
log_error "Unsupported archive format: ${archive_path}"
return 1
;;
esac
while IFS= read -r entry; do
validate_archive_entry_path "${entry}" || return 1
done <<< "${entries}"
}
extract_archive() {
local archive_path="$1"
local destination="$2"
mkdir -p "${destination}" || return 1
validate_archive_contents "${archive_path}" || return 1
case "${archive_path}" in
*.zip)
@ -664,6 +722,9 @@ EOF
}
install_standalone() {
# Return 2 only when a standalone archive is unavailable and detect mode may
# fall back to npm. Return 1 for integrity or install failures that should
# not be masked by an automatic fallback.
local target=""
local archive_name=""
local archive_path=""
@ -798,8 +859,8 @@ install_standalone() {
}
install_npm() {
require_node
require_npm
require_node || return 1
require_npm || return 1
if command_exists qwen; then
local qwen_version
@ -830,7 +891,7 @@ install_npm() {
echo "If the failure is a permission error, install Node.js with a user-owned"
echo "Node version manager or fix your npm global package directory, then run:"
echo " npm install -g @qwen-code/qwen-code@latest --registry ${NPM_REGISTRY}"
exit 1
return 1
}
print_final_instructions() {
@ -892,8 +953,13 @@ main() {
standalone_status=$?
if [[ "${standalone_status}" -eq 2 ]]; then
log_warning "Falling back to npm installation."
install_npm
print_final_instructions "$(get_npm_global_bin)"
if install_npm; then
print_final_instructions "$(get_npm_global_bin)"
else
log_warning "Standalone archive was unavailable before npm fallback; npm fallback also failed."
log_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm."
exit 1
fi
else
log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure."
exit "${standalone_status}"

View file

@ -22,7 +22,11 @@ const { execFileSync } = await vi.importActual('node:child_process');
const crypto = await vi.importActual('node:crypto');
const { tmpdir } = await vi.importActual('node:os');
const path = await vi.importActual('node:path');
const { pathToFileURL } = await vi.importActual('node:url');
const readScript = (path) => readFileSync(path, 'utf8');
const standaloneReleaseScriptUrl = pathToFileURL(
path.resolve('scripts/build-standalone-release.js'),
).href;
// These E2E cases execute the Unix shell installer and POSIX symlink behavior.
// Windows batch behavior has separate Windows-only E2E coverage below.
const itOnUnix = process.platform === 'win32' ? it.skip : it;
@ -70,6 +74,12 @@ describe('installation scripts', () => {
expect(script).toContain('detect_target()');
expect(script).toContain('verify_checksum()');
expect(script).toContain('SHA256SUMS not found; cannot verify archive');
expect(script).toContain('awk -v archive_name');
expect(script).not.toContain(
'grep -E "(^|[[:space:]])[*]?${archive_name}$"',
);
expect(script).toContain('validate_archive_contents()');
expect(script).toContain('Archive contains unsafe path');
expect(script).toContain('qwen-code-${target}');
expect(script).toContain('*.tar.xz)');
expect(script).toContain('METHOD="${METHOD:-detect}"');
@ -89,6 +99,10 @@ describe('installation scripts', () => {
expect(script).toContain('qwen-code/node/bin/node');
expect(script).toContain('Archive contains symlinks; refusing to install');
expect(script).toContain('not a Qwen Code standalone install');
expect(script).toContain(
'Return 2 only when a standalone archive is unavailable',
);
expect(script).toContain('npm fallback also failed');
expect(script).toContain(
'unzip -q "${archive_path}" -d "${destination}" || return 1',
);
@ -157,6 +171,10 @@ describe('installation scripts', () => {
expect(script).toContain(
'installer options contain unsafe command characters',
);
expect(script).toContain('[char[]](10,13,33,34');
expect(script).toContain('if "!INSTALL_BASE:~1,2!"==":/"');
expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"');
expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"');
expect(script).toContain(':ValidateVersion');
expect(script).toContain(
'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"',
@ -175,6 +193,7 @@ describe('installation scripts', () => {
expect(script).toContain('qwen-code\\node\\node.exe');
expect(script).toContain('Archive contains symlinks or reparse points');
expect(script).toContain('QWEN_INSTALL_ROOT');
expect(script).toContain('npm fallback also failed');
});
});
@ -194,11 +213,15 @@ describe('standalone release packaging', () => {
const packageScript = readScript('scripts/create-standalone-package.js');
expect(packageScript).toContain('Copyright 2025 Qwen Team');
expect(packageScript).toContain("'bundled/qc-helper/docs'");
expect(packageScript).toContain('DIST_ALLOWED_ENTRIES');
expect(packageScript).toContain('Unexpected dist asset');
expect(packageScript).toContain('topLevelDistEntryForPath(outDir)');
expect(packageScript).toContain("path.join(packageRoot, 'package.json')");
expect(packageScript).toContain('validateNodeRuntime');
expect(packageScript).toContain('assertSymlinksStayInside');
expect(packageScript).toContain('copyDereferenced');
expect(packageScript).toContain('copyNodeRuntimeEntry');
expect(packageScript).toContain('symlink cycle');
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');
@ -217,6 +240,8 @@ describe('standalone release packaging', () => {
expect(releaseScript).toContain('expectedArchiveNames');
expect(releaseScript).toContain('qwen-code-${qwenTarget}');
expect(releaseScript).toContain('scripts/create-standalone-package.js');
expect(releaseScript).toContain('--skip-checksums');
expect(releaseScript).toContain('writeSha256Sums(outDir)');
});
it('loads the standalone release packaging helper', () => {
@ -230,6 +255,48 @@ describe('standalone release packaging', () => {
expect(output).toContain('--node-version VERSION');
});
it('parses Node.js SHASUMS entries', async () => {
const { parseChecksums } = await import(standaloneReleaseScriptUrl);
const checksums = parseChecksums(
[
'a'.repeat(64) + ' node-v20.19.0-linux-x64.tar.xz',
'b'.repeat(64) + ' *node-v20.19.0-win-x64.zip',
'',
].join('\n'),
);
expect(checksums.get('node-v20.19.0-linux-x64.tar.xz')).toBe(
'a'.repeat(64),
);
expect(checksums.get('node-v20.19.0-win-x64.zip')).toBe('b'.repeat(64));
});
it('validates standalone release checksum output', async () => {
const { assertStandaloneOutput, RELEASE_TARGETS } = await import(
standaloneReleaseScriptUrl
);
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-test-'));
try {
const lines = RELEASE_TARGETS.map(({ qwenTarget }) => {
const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz';
return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`;
});
writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`);
expect(() => assertStandaloneOutput(tmpDir)).not.toThrow();
writeFileSync(
path.join(tmpDir, 'SHA256SUMS'),
`${lines.join('\n')}\n${'b'.repeat(64)} qwen-code-extra.tar.gz\n`,
);
expect(() => assertStandaloneOutput(tmpDir)).toThrow(/Extra/);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('rejects a runtime archive without a Node executable', () => {
const createdDist = ensureMinimalDist();
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
@ -372,6 +439,72 @@ describe('standalone release packaging', () => {
}
});
itOnUnix('rejects Node.js runtime symlink cycles', () => {
const createdDist = ensureMinimalDist();
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
try {
expect(() =>
execFileSync(
'node',
[
'scripts/create-standalone-package.js',
'--target',
'linux-x64',
'--node-archive',
createFakeNodeArchive(tmpDir, {
withNodeSymlinkCycle: true,
}),
'--out-dir',
path.join(tmpDir, 'out'),
'--version',
'0.0.0-test',
],
{ stdio: 'pipe' },
),
).toThrow(/symlink cycle/);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
if (createdDist) {
rmSync('dist', { recursive: true, force: true });
}
}
});
it('rejects unexpected dist assets', () => {
const createdDist = ensureMinimalDist();
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
try {
writeFileSync('dist/debug-cache.tmp', 'debug\n');
expect(() =>
execFileSync(
'node',
[
'scripts/create-standalone-package.js',
'--target',
'win-x64',
'--node-archive',
createFakeWindowsNodeArchive(tmpDir),
'--out-dir',
path.join(tmpDir, 'out'),
'--version',
'0.0.0-test',
],
{ stdio: 'pipe' },
),
).toThrow(/Unexpected dist asset/);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
if (createdDist) {
rmSync('dist', { recursive: true, force: true });
} else {
rmSync('dist/debug-cache.tmp', { force: true });
}
}
});
it('uploads standalone archives during release', () => {
const workflow = readScript('.github/workflows/release.yml');
@ -537,6 +670,27 @@ describe('Linux/macOS installer end-to-end', () => {
}
});
itOnUnix(
'rejects standalone archives containing path traversal entries',
() => {
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
try {
const archive = createTraversalStandaloneArchive(tmpDir);
expect(() =>
runUnixInstaller(
archive,
path.join(tmpDir, 'install'),
path.join(tmpDir, 'home'),
),
).toThrow(/Archive contains unsafe path/);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
},
);
itOnUnix('refuses to overwrite a non-managed install directory', () => {
const createdDist = ensureMinimalDist();
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
@ -677,6 +831,54 @@ describe('Linux/macOS installer end-to-end', () => {
}
},
);
itOnUnix('preserves context when npm fallback also fails', () => {
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
try {
const fakeBin = path.join(tmpDir, 'bin');
mkdirSync(fakeBin, { recursive: true });
writeFileSync(path.join(fakeBin, 'curl'), '#!/usr/bin/env sh\nexit 22\n');
chmodSync(path.join(fakeBin, 'curl'), 0o755);
let failureMessage = '';
try {
execFileSync(
'bash',
[
'scripts/installation/install-qwen-with-source.sh',
'--method',
'detect',
'--base-url',
'https://example.invalid/qwen-code',
'--source',
'smoke',
],
{
env: {
HOME: path.join(tmpDir, 'home'),
PATH: `${fakeBin}:/usr/bin:/bin`,
},
stdio: 'pipe',
},
);
} catch (error) {
failureMessage = [
error.message,
error.stdout?.toString() || '',
error.stderr?.toString() || '',
].join('\n');
}
expect(failureMessage).toContain('Falling back to npm installation');
expect(failureMessage).toMatch(
/Node\.js was not found|Unable to determine Node\.js version/,
);
expect(failureMessage).toContain('npm fallback also failed');
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
});
describe('Windows installer end-to-end', () => {
@ -794,6 +996,10 @@ function createFakeNodeArchive(tmpDir, options = {}) {
symlinkSync(outsideTarget, path.join(fakeNodeDir, 'bin', 'npm'));
}
if (options.withNodeSymlinkCycle) {
symlinkSync('../bin', path.join(fakeNodeDir, 'bin', 'cycle'));
}
const archive = path.join(tmpDir, 'node-v20.0.0-linux-x64.tar.gz');
execFileSync(
'tar',
@ -1061,6 +1267,38 @@ function createSymlinkStandaloneArchive(tmpDir) {
return archive;
}
function createTraversalStandaloneArchive(tmpDir) {
const maliciousRoot = path.join(tmpDir, 'malicious');
const packageRoot = path.join(maliciousRoot, 'qwen-code');
mkdirSync(path.join(packageRoot, 'bin'), { recursive: true });
mkdirSync(path.join(packageRoot, 'node', 'bin'), { recursive: true });
writeFileSync(
path.join(packageRoot, 'bin', 'qwen'),
'#!/usr/bin/env sh\necho 0.0.0-smoke\n',
);
chmodSync(path.join(packageRoot, 'bin', 'qwen'), 0o755);
writeFileSync(
path.join(packageRoot, 'node', 'bin', 'node'),
'#!/usr/bin/env sh\necho 0.0.0-smoke\n',
);
chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755);
writeFileSync(
path.join(packageRoot, 'manifest.json'),
JSON.stringify({ name: '@qwen-code/qwen-code' }),
);
writeFileSync(path.join(tmpDir, 'qwen-slip'), 'path traversal\n');
const outDir = path.join(tmpDir, 'out');
mkdirSync(outDir, { recursive: true });
const archive = path.join(outDir, 'qwen-code-linux-x64.zip');
execFileSync('zip', ['-qr', archive, 'qwen-code', '../qwen-slip'], {
cwd: maliciousRoot,
stdio: 'ignore',
});
writeChecksumFile(outDir, path.basename(archive));
return archive;
}
function writeChecksumFile(outDir, archiveName) {
const archive = path.join(outDir, archiveName);
const hash = crypto

View file

@ -6,7 +6,15 @@
import { vi } from 'vitest';
vi.mock('fs', () => ({
...vi.importActual('fs'),
appendFileSync: vi.fn(),
}));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
const appendFileSync = vi.fn();
return {
...actual,
appendFileSync,
default: {
...actual,
appendFileSync,
},
};
});