-
Notifications
You must be signed in to change notification settings - Fork 2.5k
feat(installer): verify release assets + switch public docs to standalone entrypoint #3855
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
98d2be7
05e79e7
e6a1459
3eb5c49
20f5243
0bbb5e2
523e03e
c6e4244
67cae75
ad9cdd1
a6f4bda
5481da5
178f090
5ea2bdf
4a57ea3
b268a40
59680ea
74b3ea2
54d7397
aa3b79c
895b5e7
c48174b
1f42ca3
6953e7a
d2dd787
0c90351
134ab59
07c2261
ed8636d
05191b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -127,6 +127,8 @@ | |
| expect(script).toContain('validate_https_url "${NPM_REGISTRY}"'); | ||
| expect(script).toContain('qwen-code/node/bin/node'); | ||
| expect(script).toContain('Archive contains symlinks; refusing to install'); | ||
| expect(script).toContain('Archive is empty'); | ||
|
Check failure on line 130 in scripts/tests/install-script.test.js
|
||
| expect(script).toContain('archive_contains_symlinks()'); | ||
| expect(script).toContain('not a Qwen Code standalone install'); | ||
| expect(script).toContain( | ||
| 'Return 2 only when a standalone archive is unavailable', | ||
|
|
@@ -288,6 +290,13 @@ | |
| expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); | ||
| expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); | ||
| expect(script).toContain(':ValidateVersion'); | ||
| expect(script).toContain( | ||
|
Check failure on line 293 in scripts/tests/install-script.test.js
|
||
| 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[A-Za-z0-9.-]*$"', | ||
| ); | ||
| expect(script).toContain( | ||
| 'findstr /R /C:"^v[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[A-Za-z0-9.-]*$"', | ||
| ); | ||
| expect(script).not.toContain('/C:"^v*[0-9]'); | ||
| expect(script).toContain( | ||
| 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', | ||
| ); | ||
|
|
@@ -664,7 +673,7 @@ | |
| ['scripts/build-hosted-installation-assets.js', '--help'], | ||
| { encoding: 'utf8' }, | ||
| ); | ||
| const verifierOutput = execFileSync( | ||
|
Check failure on line 676 in scripts/tests/install-script.test.js
|
||
| process.execPath, | ||
| ['scripts/verify-installation-release.js', '--help'], | ||
| { encoding: 'utf8' }, | ||
|
|
@@ -696,7 +705,7 @@ | |
| caughtError?.stdout?.toString(), | ||
| caughtError?.stderr?.toString(), | ||
| ].join('\n'), | ||
| ).toMatch(expectedOutput); | ||
|
Check failure on line 708 in scripts/tests/install-script.test.js
|
||
| }; | ||
|
|
||
| expectFail( | ||
|
|
@@ -1201,6 +1210,32 @@ | |
| await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( | ||
| /Unexpected release asset checksum: qwen-code-extra\.tar\.gz/, | ||
| ); | ||
|
|
||
| writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); | ||
| writeStandaloneReleaseChecksums( | ||
| tmpDir, | ||
| EXPECTED_STANDALONE_ARCHIVE_NAMES.slice(1), | ||
| ); | ||
| await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( | ||
| /Missing release asset checksum: qwen-code-/, | ||
| ); | ||
| } finally { | ||
| rmSync(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects unexpected files in a release directory', async () => { | ||
| const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } = | ||
| await import(installationReleaseVerificationScriptUrl); | ||
| const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); | ||
|
|
||
| try { | ||
| writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); | ||
| writeFileSync(path.join(tmpDir, '.DS_Store'), 'finder metadata\n'); | ||
|
|
||
| await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( | ||
| /Unexpected file\(s\) in release directory: \.DS_Store/, | ||
| ); | ||
| } finally { | ||
| rmSync(tmpDir, { recursive: true, force: true }); | ||
| } | ||
|
|
@@ -1278,11 +1313,62 @@ | |
| ).rejects.toThrow(/Checksum mismatch for qwen-code-/); | ||
| }); | ||
|
|
||
| it('rejects remote SHA256SUMS responses that are unavailable', async () => { | ||
| const { verifyReleaseBaseUrl } = await import( | ||
| installationReleaseVerificationScriptUrl | ||
| ); | ||
|
|
||
| await expect( | ||
| verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { | ||
| fetchImpl: async () => new Response('missing', { status: 404 }), | ||
| }), | ||
| ).rejects.toThrow(/Failed to download .*SHA256SUMS: 404/); | ||
| }); | ||
|
|
||
| it('rejects remote SHA256SUMS with missing or extra archive entries', async () => { | ||
| const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = | ||
| await import(installationReleaseVerificationScriptUrl); | ||
|
|
||
| await expect( | ||
| verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { | ||
| fetchImpl: async (url) => { | ||
| if (url.endsWith('/SHA256SUMS')) { | ||
| return new Response( | ||
| placeholderChecksumContent( | ||
| EXPECTED_STANDALONE_ARCHIVE_NAMES.slice(1), | ||
| ), | ||
| ); | ||
| } | ||
| return new Response(null, { status: 200 }); | ||
| }, | ||
| }), | ||
| ).rejects.toThrow(/Missing release asset checksum: qwen-code-/); | ||
|
|
||
| await expect( | ||
| verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { | ||
| fetchImpl: async (url) => { | ||
| if (url.endsWith('/SHA256SUMS')) { | ||
| return new Response( | ||
| placeholderChecksumContent([ | ||
| ...EXPECTED_STANDALONE_ARCHIVE_NAMES, | ||
| 'qwen-code-extra.tar.gz', | ||
| ]), | ||
| ); | ||
| } | ||
| return new Response(null, { status: 200 }); | ||
| }, | ||
| }), | ||
| ).rejects.toThrow( | ||
| /Unexpected release asset checksum: qwen-code-extra\.tar\.gz/, | ||
| ); | ||
| }); | ||
|
|
||
| it('rejects a release base URL that is not https', async () => { | ||
| const { verifyReleaseBaseUrl } = await import( | ||
| installationReleaseVerificationScriptUrl | ||
| ); | ||
|
|
||
| // file:// must be rejected as a URL the verifier cannot reach safely. | ||
| await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow( | ||
| /--base-url must use https/, | ||
| ); | ||
|
|
@@ -1811,7 +1897,7 @@ | |
| expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_SECRET'); | ||
| expect(guide).toContain('ALIYUN_OSS_BUCKET'); | ||
| expect(guide).toContain('ALIYUN_OSS_ENDPOINT'); | ||
| expect(guide).toContain('Public installation documentation'); | ||
|
Check failure on line 1900 in scripts/tests/install-script.test.js
|
||
| expect(guide).toContain('node-pty'); | ||
| expect(guide).toContain('clipboard'); | ||
| }); | ||
|
|
@@ -1869,7 +1955,7 @@ | |
| const archive = packageFakeStandalone(tmpDir); | ||
| const installRoot = path.join(tmpDir, 'install'); | ||
| const home = path.join(tmpDir, 'home'); | ||
| const output = runUnixInstaller(archive, installRoot, home).toString(); | ||
| runUnixInstaller(archive, installRoot, home); | ||
|
|
||
| expect(existsSync(path.join(installRoot, 'bin', 'qwen'))).toBe(true); | ||
| expect( | ||
|
|
@@ -1887,26 +1973,26 @@ | |
| .toString() | ||
| .trim(); | ||
| expect(version).toBe('0.0.0-smoke'); | ||
| expect(output).toContain('Installing Qwen Code version: latest'); | ||
|
Check failure on line 1976 in scripts/tests/install-script.test.js
|
||
| expect(output).toContain('QWEN CODE'); | ||
| expect(output).toContain( | ||
| 'Qwen Code 0.0.0-smoke installed successfully.', | ||
| ); | ||
| expect(output).toContain('To start:\n cd <project>\n qwen'); | ||
| expect(output).toContain( | ||
| `Installed to:\n ${path.join(installRoot, 'lib', 'qwen-code')}`, | ||
| ); | ||
| expect(output).toContain('Uninstall:'); | ||
| expect(output).toContain( | ||
| 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh', | ||
| ); | ||
| expect(output).toContain( | ||
| `QWEN_INSTALL_LIB_DIR='${path.join(installRoot, 'lib', 'qwen-code')}'`, | ||
| ); | ||
| expect(output).toContain( | ||
| `QWEN_INSTALL_BIN_DIR='${path.join(installRoot, 'bin')}'`, | ||
| ); | ||
| expect(output).not.toContain('rm -rf'); | ||
| } finally { | ||
| rmSync(tmpDir, { recursive: true, force: true }); | ||
| restoreMinimalDist(createdDist); | ||
|
|
@@ -2630,6 +2716,64 @@ | |
| } | ||
| }); | ||
|
|
||
| itOnUnix('rejects archive symlinks before extraction', () => { | ||
| const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); | ||
|
|
||
| try { | ||
| const archive = createSymlinkStandaloneArchive(tmpDir); | ||
| const tarWrapperDir = path.join(tmpDir, 'bin'); | ||
| const marker = path.join(tmpDir, 'tar-extraction-attempted'); | ||
| mkdirSync(tarWrapperDir, { recursive: true }); | ||
| writeFileSync( | ||
| path.join(tarWrapperDir, 'tar'), | ||
| [ | ||
| '#!/usr/bin/env bash', | ||
| 'if [[ "$1" == "-xzf" || "$1" == "-xf" ]]; then', | ||
| ' touch "$QWEN_TAR_EXTRACT_MARKER"', | ||
| 'fi', | ||
| 'exec "$QWEN_REAL_TAR" "$@"', | ||
| '', | ||
| ].join('\n'), | ||
| ); | ||
| chmodSync(path.join(tarWrapperDir, 'tar'), 0o755); | ||
|
|
||
| expect(() => | ||
| runUnixInstaller( | ||
| archive, | ||
| path.join(tmpDir, 'install'), | ||
| path.join(tmpDir, 'home'), | ||
| 'standalone', | ||
| { | ||
| PATH: `${tarWrapperDir}${path.delimiter}${process.env.PATH}`, | ||
| QWEN_REAL_TAR: execFileSync('which', ['tar']).toString().trim(), | ||
| QWEN_TAR_EXTRACT_MARKER: marker, | ||
| }, | ||
| ), | ||
| ).toThrow(/Archive contains symlinks/); | ||
| expect(existsSync(marker)).toBe(false); | ||
|
Check failure on line 2753 in scripts/tests/install-script.test.js
|
||
| } finally { | ||
| rmSync(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| itOnUnix('rejects empty standalone archives with a clear error', () => { | ||
| const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); | ||
|
|
||
| try { | ||
| const archive = createEmptyStandaloneArchive(tmpDir); | ||
|
|
||
| expect(() => | ||
| runUnixInstaller( | ||
| archive, | ||
| path.join(tmpDir, 'install'), | ||
| path.join(tmpDir, 'home'), | ||
| ), | ||
| ).toThrow(/Archive is empty/); | ||
|
Check failure on line 2771 in scripts/tests/install-script.test.js
|
||
| } finally { | ||
| rmSync(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| itOnUnix( | ||
| 'rejects standalone archives containing path traversal entries', | ||
| () => { | ||
|
|
@@ -3802,6 +3946,17 @@ | |
| return archive; | ||
| } | ||
|
|
||
| function createEmptyStandaloneArchive(tmpDir) { | ||
| const outDir = path.join(tmpDir, 'out'); | ||
|
yiliang114 marked this conversation as resolved.
Outdated
|
||
| mkdirSync(outDir, { recursive: true }); | ||
| const archive = path.join(outDir, 'qwen-code-linux-x64.tar.gz'); | ||
| execFileSync('tar', ['-czf', archive, '-T', '/dev/null'], { | ||
| stdio: 'ignore', | ||
| }); | ||
| writeChecksumFile(outDir, path.basename(archive)); | ||
| return archive; | ||
| } | ||
|
|
||
| function createTraversalStandaloneArchive(tmpDir) { | ||
| const maliciousRoot = path.join(tmpDir, 'malicious'); | ||
| const packageRoot = path.join(maliciousRoot, 'qwen-code'); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.