mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 16:28:28 +00:00
fix(installer): harden standalone review fixes
This commit is contained in:
parent
e7e3f9077d
commit
fee51d1d91
6 changed files with 496 additions and 86 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue