From fee51d1d9130f68f65c931096fbe80bef960efaf Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 4 May 2026 17:38:07 +0800 Subject: [PATCH] fix(installer): harden standalone review fixes --- scripts/build-standalone-release.js | 21 +- scripts/create-standalone-package.js | 208 ++++++++++----- .../installation/install-qwen-with-source.bat | 9 +- .../installation/install-qwen-with-source.sh | 86 ++++++- scripts/tests/install-script.test.js | 242 +++++++++++++++++- scripts/tests/test-setup.ts | 16 +- 6 files changed, 496 insertions(+), 86 deletions(-) diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index efb82b062..b0bf2bd17 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -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 }; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index e2f5c821b..968d6da45 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -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 }; diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index a7b1d8ea2..c8ce8cf06 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -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 diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 12e61922b..1d8c5d7d7 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -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:-}" + 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}" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 574f89094..0f70578f0 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -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 diff --git a/scripts/tests/test-setup.ts b/scripts/tests/test-setup.ts index d4c4b4655..5a769fc6c 100644 --- a/scripts/tests/test-setup.ts +++ b/scripts/tests/test-setup.ts @@ -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('fs'); + const appendFileSync = vi.fn(); + return { + ...actual, + appendFileSync, + default: { + ...actual, + appendFileSync, + }, + }; +});