mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
* 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.
502 lines
14 KiB
JavaScript
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,
|
|
};
|