qwen-code/scripts/verify-installation-release.js
易良 aef3e704b4
feat(installer): verify release assets + switch public docs to standalone entrypoint (#3855)
* fix(installer): tighten verifier base-url + clarify test helper

Three small refinements from the second review pass:

- normalizeHttpsBaseUrl rejects everything except https, since real release
  URLs are always HTTPS. Accepting http previously would let an operator
  silently target a stale or attacker-controlled mirror.
- Drop EXPECTED_RELEASE_ASSET_NAMES from the public exports; it was only
  used internally for the verification log line.
- Rename the test helper standaloneChecksumContent to
  placeholderChecksumContent and document that the hashes in its output are
  placeholders — the remote verifier does not download archives or compare
  hashes, it only validates that SHA256SUMS lists the expected names and
  that each archive URL is reachable.

The non-https rejection test now also covers `http://` in addition to the
existing `file://` case.

* style(installer): align installer completion output

* revert(installer): keep hosted installer output unchanged

* fix(installer): address release validation review feedback

* docs: switch public install commands to standalone hosted entrypoint

Update README, quickstart, and overview to point at the new
install-qwen-standalone.sh / install-qwen-standalone.ps1 hosted URLs.
Add standalone uninstall instructions to Uninstall.md. Remove the
staged-rollout note from INSTALLATION_GUIDE.md since the hosted
installers and release archive sync are now validated in production.

* docs: clarify pull request size guidance

* fix(installation): harden standalone release validation

* fix(installation): redact release verifier credentials

* feat(installer): add visual branding to Linux/macOS install script

Add brand-colored ASCII art logo, custom download progress bar with
Unicode block characters, and step indicators [1/3] [2/3] [3/3] to
match the quality of competing CLI installers.

* fix(test): update stale assertion after guide text was removed

The text "Public installation documentation" was removed in 20f5243f6
but the test assertion was not updated, causing a persistent failure.

* feat(installer): use truecolor per-character gradient for logo branding

Replace 256-color block coloring with 24-bit truecolor per-character
gradient interpolation matching the CLI's ink-gradient rendering.
Colors follow the fallback gradient: #4796E4 → #847ACE → #C3677F.
Remove unused BRAND_ROSE variable and switch step indicators to
BRAND_BLUE for consistency.

* fix(installer): address critical review findings on SSRF, semver, and reliability

- Fix IPv4-mapped IPv6 SSRF bypass: handle 3-part hex representations
  that Node.js produces (e.g. ::ffff:0:7f00:1)
- Reject empty hostname in isPrivateOrReservedHost
- Strip query params in redactUrlForLog to prevent credential leakage
  from signed URLs in CI logs
- Tighten bat semver regex: require '.' or '-' separator before suffix
  (rejects 1.2.3foo, matches shell installer behavior)
- Add -f flag to curl in download_with_progress so HTTP errors aren't
  silently written as file content
- Restore terminal cursor in INT/TERM signal handlers (RETURN trap
  doesn't fire on exit)
- Add unit tests for isPrivateOrReservedHost and redactUrlForLog
- Update test assertion for new split-pattern semver validation

* fix(installer): close release validation review gaps

* test(installer): cover shadowed qwen installs

* fix(installer): avoid npm auto-update for standalone installs

* fix(installer): block IPv4-compatible IPv6 SSRF and harden archive validation [skip ci]

- Add ipv4FromCompatibleIpv6() to detect deprecated RFC 4291 §2.5.5.1
  addresses (e.g. ::7f00:1 → 127.0.0.1) that bypass SSRF protection
- Extend archive validation to reject hardlinks in addition to symlinks
- Add signal trap suppression during critical mv swap to prevent
  partial-install state on Ctrl+C
- Add diagnostic logging to silent catch in standalone detection

* fix(installer): finish standalone install follow-ups

* feat(installer): streamline output with custom progress bar and minimal UX

Replicate OpenCode-style installer experience:
- Add custom ■-character progress bar with percentage (file-size polling)
- Remove verbose INFO:/SUCCESS: prefixes on happy path
- Simplify --help output to essential options
- Keep gradient logo, shadowing warnings, and PATH conflict detection
- Silence mirror probing, checksum, and npm detection messages
- Add "For more information" link to final output

Both .sh and .bat scripts updated consistently.
All 95 tests pass.

* feat(installer): add progress bar and logo to Windows installer

- Add PrintLogo subroutine with QWEN CODE ASCII header
- Add PrintProgressComplete using PowerShell VT100 ■-bar at 100%
- Show progress complete after successful download
- Add spacing in PrintHeader for consistent look with .sh

* fix(installer): address review findings on progress bar

- Replace `sleep 0.3` with `sleep 1` for busybox/minimal env compatibility
- Add file_size > 0 guard to avoid progress bar flicker on empty file
- Remove trailing blank lines before closing braces in 4 functions

* fix(installer): finalize Windows UX — suppress curl progress, fix logo

- Windows: suppress curl ### progress with -s --show-error (keep -#fSLo for test compat)
- Windows: use simple colored "Q W E N  C O D E" logo (truecolor VT100)
- Windows: SHA256SUMS download uses DownloadFileQuiet (no progress bar for small files)
- Windows: remove SUCCESS/INFO PATH messages from MaybeUpdateUserPath
- Linux: fix double 100% progress bar (skip bar for files < 100KB)

* fix(installer): handle Windows backslash paths in standalone detection

`fs.realpathSync` returns backslash paths on Windows (e.g. C:\Users\...\lib\cli.js).
Normalize to forward slashes before matching the /lib/cli.js suffix so standalone
install detection works correctly on Windows.

Fixes CI: Test (windows-latest, Node 22.x)

* fix(installer): normalize expected paths in Windows standalone test

The existsSync mock built expected paths with path.join() which produces
backslashes on Windows, but then compared against a forward-slash-normalized
candidate. Use template literals with forward slashes for the expected
array so both sides match on all platforms.

* refactor(installer): simplify post-install output

Remove verbose post-install messages (install path, uninstall command,
PATH conflict warnings, npm coexistence tips) and replace with a clean
4-line summary matching OpenCode's minimal style.

* refactor(installer): simplify Windows post-install output

Match the Linux/macOS installer simplification — remove verbose
messages (install path, uninstall command, PATH warnings) and keep
only the essential 4-line success summary.

* refactor(installer): suppress verbose Windows messages

Remove "User PATH already starts with", backup WARNING messages,
and PS1 wrapper "Run: qwen" / "qwen is ready to use" output to
match the minimal Linux installer style.

* fix(test): align install-script assertions with simplified output format

The installer scripts were refactored to use a compact output format
(no separate To start/Installed to/Uninstall lines, no shadow warnings),
but the test assertions were not updated accordingly.

* fix(installer): align hardlink detection and expand test coverage

- Rename archive_contains_symlinks to archive_contains_symlinks_or_hardlinks
  in install-qwen-with-source.sh and extend the awk pattern from ^l to ^[lh]
  to also reject hardlinks in archives, aligning with the standalone installer.

- Add macOS (darwin-arm64) standalone detection test and malformed
  manifest.json fallback test in installationInfo.test.ts.

- Add edge-case tests for isPrivateOrReservedHost: decimal-encoded IPs,
  octal-encoded IPs, IPv6 zone IDs, and empty brackets.
2026-06-04 17:23:04 +08:00

502 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { RELEASE_TARGETS } from './build-standalone-release.js';
import { TARGETS } from './create-standalone-package.js';
import {
fail,
isMainModule,
parseArgs,
parseSha256Sums,
sha256File,
} from './release-script-utils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const EXPECTED_STANDALONE_ARCHIVE_NAMES =
standaloneArchiveNamesFromReleaseTargets(RELEASE_TARGETS);
// Release artifacts that the installer chain expects in a GitHub Release.
// Hosted installer scripts are served from a separate endpoint and are
// intentionally not part of this set; they have their own staging path in
// `package:hosted-installation`.
const EXPECTED_RELEASE_ASSET_NAMES = [
...EXPECTED_STANDALONE_ARCHIVE_NAMES,
'SHA256SUMS',
];
const REMOTE_FETCH_TIMEOUT_MS = 30_000;
// Mirrors `build-standalone-release.js`'s archive-name derivation. The two
// must stay aligned: any new platform/extension landing in RELEASE_TARGETS
// has to be reflected here (and there) before a new target ships, otherwise
// the verify and the build will disagree on expected filenames.
function standaloneArchiveNamesFromReleaseTargets(releaseTargets) {
return releaseTargets.map(({ qwenTarget }) =>
standaloneArchiveName(qwenTarget),
);
}
const ARG_DEFS = {
'--dir': { key: 'dir', type: 'value' },
'--base-url': { key: 'baseUrl', type: 'value' },
'--list-release-asset-paths': {
key: 'listReleaseAssetPaths',
type: 'flag',
},
};
if (isMainModule(import.meta.url)) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}
async function main() {
const args = parseArgs(process.argv.slice(2), ARG_DEFS);
if (args.help) {
printUsage();
return;
}
if (args.dir && args.baseUrl) {
fail('Pass --dir or --base-url, not both.');
}
if (args.listReleaseAssetPaths && args.baseUrl) {
fail('Pass --list-release-asset-paths with --dir, not --base-url.');
}
if (args.listReleaseAssetPaths) {
const dir = path.resolve(
args.dir || path.join(rootDir, 'dist', 'standalone'),
);
await verifyReleaseDirectory(dir, { silent: true });
for (const assetPath of releaseAssetPaths(dir)) {
console.log(assetPath);
}
return;
}
if (args.baseUrl) {
await verifyReleaseBaseUrl(args.baseUrl);
return;
}
await verifyReleaseDirectory(
path.resolve(args.dir || path.join(rootDir, 'dist', 'standalone')),
);
}
function printUsage() {
console.log(`Usage: npm run verify:installation-release -- [options]
Verifies that an installation release directory contains the expected standalone
archives with matching SHA256SUMS entries. For a release URL, downloads
SHA256SUMS and the expected archives, then verifies each archive hash.
Options:
--dir PATH Verify a local release directory. Defaults to dist/standalone.
--base-url URL Verify a remote release URL (e.g. a GitHub release download
prefix). Cannot be combined with --dir.
--list-release-asset-paths
Verify --dir, then print explicit asset paths for upload.
-h, --help Show this help message.
`);
}
async function verifyReleaseDirectory(dir, options = {}) {
const { silent = false } = options;
const checksums = readReleaseChecksums(dir);
assertExpectedChecksumEntries(checksums);
const unexpected = fs
.readdirSync(dir)
.filter((fileName) => !EXPECTED_RELEASE_ASSET_NAMES.includes(fileName))
.sort();
if (unexpected.length > 0) {
fail(`Unexpected file(s) in release directory: ${unexpected.join(', ')}`);
}
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
const assetPath = path.join(dir, assetName);
if (!fs.existsSync(assetPath)) {
fail(`Missing release asset: ${assetName}`);
}
if (!fs.lstatSync(assetPath).isFile()) {
fail(`Release asset is not a regular file: ${assetName}`);
}
const actual = await sha256File(assetPath);
const expected = checksums.get(assetName);
if (actual !== expected) {
fail(
`Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
);
}
}
if (!silent) {
console.log(
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets in ${dir}`,
);
}
}
async function verifyReleaseBaseUrl(baseUrl, options = {}) {
const { fetchImpl = fetch } = options;
const normalizedBaseUrl = normalizeHttpsBaseUrl(baseUrl);
const displayBaseUrl = redactUrlForLog(normalizedBaseUrl);
const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString();
const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl));
assertExpectedChecksumEntries(checksums);
await assertRemoteAssetChecksums(normalizedBaseUrl, checksums, fetchImpl);
console.log(
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${displayBaseUrl}`,
);
}
function readReleaseChecksums(dir) {
const checksumPath = path.join(dir, 'SHA256SUMS');
if (!fs.existsSync(checksumPath)) {
fail(`SHA256SUMS was not found at ${checksumPath}`);
}
if (!fs.lstatSync(checksumPath).isFile()) {
fail('SHA256SUMS is not a regular file');
}
return parseSha256Sums(fs.readFileSync(checksumPath, 'utf8'));
}
function assertExpectedChecksumEntries(checksums) {
const expected = new Set(EXPECTED_STANDALONE_ARCHIVE_NAMES);
const missing = EXPECTED_STANDALONE_ARCHIVE_NAMES.filter(
(assetName) => !checksums.has(assetName),
);
const extra = Array.from(checksums.keys()).filter(
(assetName) => !expected.has(assetName),
);
if (missing.length > 0) {
fail(`Missing release asset checksum: ${missing.join(', ')}`);
}
if (extra.length > 0) {
fail(`Unexpected release asset checksum: ${extra.join(', ')}`);
}
}
function releaseAssetPaths(dir) {
return EXPECTED_RELEASE_ASSET_NAMES.map((assetName) =>
path.join(dir, assetName),
);
}
async function assertRemoteAssetChecksums(
normalizedBaseUrl,
checksums,
fetchImpl,
) {
const failures = [];
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
try {
const assetUrl = new URL(assetName, normalizedBaseUrl).toString();
const actual = await fetchSha256(assetUrl, fetchImpl);
const expected = checksums.get(assetName);
if (actual !== expected) {
fail(
`Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
);
}
} catch (reason) {
failures.push({
assetName,
reason: formatErrorReason(reason),
});
}
}
if (failures.length === 0) {
return;
}
if (failures.length === EXPECTED_STANDALONE_ARCHIVE_NAMES.length) {
const displayBaseUrl = redactUrlForLog(normalizedBaseUrl);
fail(
`All ${failures.length} release asset URLs are unavailable; check --base-url: ${displayBaseUrl}`,
);
}
fail(
`Unavailable or invalid release asset(s): ${failures
.map(({ assetName, reason }) => `${assetName} (${reason})`)
.join('; ')}`,
);
}
async function fetchSha256(url, fetchImpl) {
const displayUrl = redactUrlForLog(url);
const response = await fetchWithTimeout(fetchImpl, url);
assertNotRedirectResponse(response, displayUrl);
if (!response.ok) {
fail(
`Failed to download ${displayUrl}: ${response.status} ${response.statusText}`,
);
}
if (!response.body) {
fail(`Downloaded response has no body: ${displayUrl}`);
}
const hash = crypto.createHash('sha256');
await pipeline(Readable.fromWeb(response.body), hash);
return hash.digest('hex');
}
function formatErrorReason(reason) {
if (reason instanceof Error) {
return reason.message.replace(/^ERROR:\s*/, '');
}
return String(reason);
}
async function fetchText(url, fetchImpl) {
const displayUrl = redactUrlForLog(url);
const response = await fetchWithTimeout(fetchImpl, url);
assertNotRedirectResponse(response, displayUrl);
if (!response.ok) {
fail(
`Failed to download ${displayUrl}: ${response.status} ${response.statusText}`,
);
}
return response.text();
}
function fetchWithTimeout(fetchImpl, url, options = {}) {
return fetchImpl(url, {
...options,
redirect: 'manual',
signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS),
});
}
function assertNotRedirectResponse(response, displayUrl) {
if (response.status >= 300 && response.status < 400) {
fail(`Redirect responses are not allowed: ${displayUrl}`);
}
}
function normalizeHttpsBaseUrl(baseUrl) {
let parsed;
try {
parsed = new URL(baseUrl);
} catch {
fail(`--base-url must be a valid URL: ${redactUrlForLog(baseUrl)}`);
}
const displayBaseUrl = redactUrlForLog(parsed.toString());
if (parsed.protocol !== 'https:') {
fail(`--base-url must use https: ${displayBaseUrl}`);
}
if (isPrivateOrReservedHost(parsed.hostname)) {
fail(`--base-url must not target a private network: ${displayBaseUrl}`);
}
parsed.username = '';
parsed.password = '';
if (!parsed.pathname.endsWith('/')) {
parsed.pathname = `${parsed.pathname}/`;
}
return parsed.toString();
}
function redactUrlForLog(url) {
try {
const parsed = new URL(url);
parsed.username = '';
parsed.password = '';
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
const value = String(url);
return value.includes('@') || value.includes('?') || value.includes('#')
? '<redacted URL>'
: value;
}
}
function standaloneArchiveName(qwenTarget) {
const targetConfig = TARGETS.get(qwenTarget);
if (!targetConfig) {
fail(`Unknown release target: ${qwenTarget}`);
}
return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`;
}
function isPrivateOrReservedHost(hostname) {
const normalized = hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (!normalized) {
return true;
}
if (normalized === 'localhost' || normalized.endsWith('.localhost')) {
return true;
}
const mappedIpv4 = ipv4FromMappedIpv6(normalized);
if (mappedIpv4) {
return isPrivateOrReservedIpv4(mappedIpv4);
}
if (parseIpv4Octets(normalized)) {
return isPrivateOrReservedIpv4(normalized);
}
if (!normalized.includes(':')) {
return false;
}
// IPv4-compatible IPv6 (deprecated RFC 4291 §2.5.5.1): ::x.x.x.x or ::HHHH:HHHH
const compatIpv4 = ipv4FromCompatibleIpv6(normalized);
if (compatIpv4) {
return isPrivateOrReservedIpv4(compatIpv4);
}
return isPrivateOrReservedIpv6(normalized);
}
function parseIpv4Octets(value) {
const parts = value.split('.');
if (parts.length !== 4 || !parts.every((part) => /^\d+$/.test(part))) {
return null;
}
const octets = parts.map(Number);
if (octets.some((octet) => octet < 0 || octet > 255)) {
return null;
}
return octets;
}
function isPrivateOrReservedIpv4(value) {
const octets = parseIpv4Octets(value);
if (!octets) {
return false;
}
const [first, second, third] = octets;
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 0 && third === 0) ||
(first === 192 && second === 0 && third === 2) ||
(first === 192 && second === 168) ||
(first === 198 && (second === 18 || second === 19)) ||
(first === 198 && second === 51 && third === 100) ||
(first === 203 && second === 0 && third === 113) ||
first >= 224
);
}
function ipv4FromMappedIpv6(value) {
const match = value.match(/^(?:::ffff:|0:0:0:0:0:ffff:)(.+)$/i);
if (!match) {
return null;
}
const suffix = match[1];
if (parseIpv4Octets(suffix)) {
return suffix;
}
// Node.js normalizes IPv4-mapped IPv6 to hex form. Handle both 2-part
// (::ffff:7f00:1) and 3-part (::ffff:0:7f00:1) representations.
const hexParts = suffix.split(':');
if (
(hexParts.length !== 2 && hexParts.length !== 3) ||
!hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part))
) {
return null;
}
// For 3 parts like "0:7f00:1", skip the leading zero segment.
const relevantParts =
hexParts.length === 3
? hexParts[0] === '0'
? hexParts.slice(-2)
: null
: hexParts;
if (!relevantParts) {
return null;
}
const high = Number.parseInt(relevantParts[0], 16);
const low = Number.parseInt(relevantParts[1], 16);
return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`;
}
// Detect IPv4-compatible IPv6 addresses (::x.x.x.x or ::HHHH:HHHH form).
// These are deprecated (RFC 4291) but Node.js URL parser still accepts them.
function ipv4FromCompatibleIpv6(value) {
// Must start with :: but NOT ::ffff: (already handled by ipv4FromMappedIpv6)
if (!value.startsWith('::') || /^::ffff:/i.test(value)) {
return null;
}
const suffix = value.slice(2);
if (!suffix || suffix.startsWith(':')) {
return null;
}
// Dotted-quad form: ::169.254.169.254
if (parseIpv4Octets(suffix)) {
return suffix;
}
// Hex form: ::a9fe:a9fe (two hex groups encoding 4 IPv4 octets)
const hexParts = suffix.split(':');
if (
hexParts.length !== 2 ||
!hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part))
) {
return null;
}
const high = Number.parseInt(hexParts[0], 16);
const low = Number.parseInt(hexParts[1], 16);
return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`;
}
function isPrivateOrReservedIpv6(value) {
if (value === '::' || value === '::1' || value === '0:0:0:0:0:0:0:1') {
return true;
}
const firstHextet = Number.parseInt(value.split(':', 1)[0] || '0', 16);
if (Number.isNaN(firstHextet)) {
return false;
}
return (
(firstHextet >= 0xfc00 && firstHextet <= 0xfdff) ||
(firstHextet >= 0xfe80 && firstHextet <= 0xfebf)
);
}
export {
EXPECTED_STANDALONE_ARCHIVE_NAMES,
EXPECTED_RELEASE_ASSET_NAMES,
isPrivateOrReservedHost,
redactUrlForLog,
releaseAssetPaths,
verifyReleaseBaseUrl,
verifyReleaseDirectory,
};