/** * `ccs bar install` — download the CCS Bar app from the floating * `ccs-bar-latest` GitHub release tag and install to ~/Applications. * * Intentionally uses a FLOATING tag (not the exact CLI version) so the * Swift app can be rebuilt and published independently. After extraction, * the real app version is read from the bundle's Contents/Info.plist * (CFBundleShortVersionString) and pinned to ~/.ccs/bar/.version. * * Post-install compat check: single GET {baseUrl}/api/bar/summary. * 200 = server serves the bar API (compatible). * 404 = server too old (actionable warning). * Other / unreachable = soft-warn, never hard-fail install. * * Mirrors the download/version-pin pattern in src/cliproxy/binary-manager.ts. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../../config/config-loader-facade'; import { hasAnyFlag } from '../arg-extractor'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Floating release tag — never pin to exact CLI semver. */ const BAR_RELEASE_TAG = 'ccs-bar-latest'; const BAR_APP_NAME = 'CCS Bar.app'; const BAR_ASSET_NAME = 'CCS-Bar.app.zip'; const BAR_GITHUB_REPO = 'kaitranntt/ccs'; /** * Allowlist of hostnames from which we will accept asset downloads. * GitHub releases redirect from github.com to objects.githubusercontent.com. * * TODO(checksum-v2): once release assets ship a checksums.txt/.sha256 file, * wire SHA-256 verification here. The download URL is already validated for * host+HTTPS as a v1 minimum guard. The verifier hook below is the intended * extension point. */ const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray = [ 'github.com', 'objects.githubusercontent.com', ]; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ReleaseAssetResult { downloadUrl: string; } export interface CompatResult { compatible: boolean; reason: 'ok' | 'no-bar-api' | 'unreachable'; } export interface InstallDeps { /** * Resolve the asset download URL from a GitHub release tag. * Production: calls GitHub API releases/tags/{tag}. * Test: mock that returns a fake URL. */ fetchReleaseAsset: (tag: string, asset: string) => Promise; /** * Download the zip archive and extract the .app bundle into dest/. * Production: uses undici to stream + extract (with redirect + status check). */ downloadAndExtract: (url: string, dest: string) => Promise; /** * GET {baseUrl}/api/bar/summary — capability handshake. * 200 → compatible; 404 → no-bar-api; else/unreachable → unreachable. * Never hard-fails install. */ verifyCompat: (baseUrl: string) => Promise; /** * Read CFBundleShortVersionString from {appPath}/Contents/Info.plist. * Returns null if the file is absent or unreadable. */ readAppBundleVersion: (appPath: string) => string | null; /** Returns path to ~/.ccs (respects CCS_HOME). */ getCcsDir: () => string; /** Destination directory for the .app bundle (~/Applications by default). */ getAppsDir: () => string; /** * Clear the macOS Gatekeeper quarantine attribute from the installed app. * Run via `xattr -dr com.apple.quarantine ` (execFile, not shell-string). * Returns true on success, false if xattr is unavailable or the call fails (non-fatal). * Injectable for tests — avoids touching the real system. */ clearQuarantine: (appPath: string) => Promise; /** * Invoke handleBarLaunch after a successful install when the user consents. * Injectable so tests can assert invocation without starting a real server. */ launchBar: (args: string[]) => Promise; /** * Ask the user whether to launch CCS Bar now (TTY-only). * Returns true on yes/default, false on no or non-TTY. * Injectable for tests. */ promptLaunch: () => Promise; } // --------------------------------------------------------------------------- // Host allowlist validation (Finding #9) // --------------------------------------------------------------------------- /** * Validate that a download URL is https and its hostname is in the allowlist * (exact match or *.githubusercontent.com wildcard). * Throws a descriptive Error if validation fails. */ export function validateDownloadUrl(url: string): void { let parsed: URL; try { parsed = new URL(url); } catch { throw new Error(`Download URL is not a valid URL: ${url}`); } if (parsed.protocol !== 'https:') { throw new Error(`Download URL must use HTTPS, got: ${parsed.protocol} for ${url}`); } const host = parsed.hostname.toLowerCase(); const allowed = (DOWNLOAD_HOST_ALLOWLIST as string[]).includes(host) || host.endsWith('.githubusercontent.com'); if (!allowed) { throw new Error( `Download URL hostname "${host}" is not in the trusted allowlist ` + `(${DOWNLOAD_HOST_ALLOWLIST.join(', ')}, *.githubusercontent.com). ` + `Refusing to download from untrusted host.` ); } } // --------------------------------------------------------------------------- // Production implementation helpers // --------------------------------------------------------------------------- async function defaultFetchReleaseAsset(tag: string, asset: string): Promise { const { request } = await import('undici'); const apiUrl = `https://api.github.com/repos/${BAR_GITHUB_REPO}/releases/tags/${tag}`; const { statusCode, body } = await request(apiUrl, { headers: { 'User-Agent': 'ccs-cli', Accept: 'application/vnd.github+json', }, }); if (statusCode !== 200) { throw new Error(`GitHub API returned ${statusCode} for tag ${tag}`); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const release = (await body.json()) as any; // eslint-disable-next-line @typescript-eslint/no-explicit-any const found = (release.assets as any[]).find((a: { name: string }) => a.name === asset); if (!found) { throw new Error(`Asset "${asset}" not found in release ${tag}`); } return { downloadUrl: found.browser_download_url as string }; } /** * Download a zip from `url` (following up to 5 GitHub 302 redirects, re-validating * each hop's Location hostname) and extract its contents into `dest`. * * Fixes applied: * - Fix #6: maxRedirections:0 + manual redirect following so every hop's Location * header is passed through validateDownloadUrl before following it. * The previous maxRedirections:5 let undici follow redirects to ANY host unchecked. * - Fix #14: list zip entries with `unzip -l` before extracting; reject the archive * if any entry path contains ".." or starts with "/" (zip-slip guard). * - Finding #11: check `statusCode` and throw a descriptive error before streaming. * - Finding #9: validate host+HTTPS before making the first request. */ async function defaultDownloadAndExtract(url: string, dest: string): Promise { const { request } = await import('undici'); const { createWriteStream, mkdirSync } = fs; const { promisify } = await import('util'); const { pipeline } = await import('stream'); const streamPipeline = promisify(pipeline); const { execFile } = await import('child_process'); const execFileAsync = promisify(execFile); // Validate initial URL (Finding #9) validateDownloadUrl(url); mkdirSync(dest, { recursive: true }); // Fix #6: follow redirects manually so each hop is re-validated. const MAX_REDIRECTS = 5; let currentUrl = url; let redirectsFollowed = 0; while (true) { const { statusCode, headers, body } = await request(currentUrl, { maxRedirections: 0, // disable undici's auto-follow; we follow manually }); if (statusCode >= 300 && statusCode < 400) { const location = Array.isArray(headers['location']) ? headers['location'][0] : headers['location']; if (!location) { throw new Error(`Redirect (HTTP ${statusCode}) from ${currentUrl} has no Location header`); } // Resolve relative redirects against the current URL const resolved = new URL(location, currentUrl).toString(); // Re-validate the redirect target — this is the key fix for #6 validateDownloadUrl(resolved); if (redirectsFollowed >= MAX_REDIRECTS) { throw new Error(`Too many redirects (>${MAX_REDIRECTS}) while downloading ${url}`); } // Drain the body to free the socket before following the redirect // eslint-disable-next-line @typescript-eslint/no-explicit-any await (body as any).dump?.(); currentUrl = resolved; redirectsFollowed++; continue; } if (statusCode !== 200) { throw new Error(`Download failed: HTTP ${statusCode} for ${currentUrl}`); } const tmpZip = path.join(os.tmpdir(), `ccs-bar-${Date.now()}.zip`); // Stream body to tmpZip // eslint-disable-next-line @typescript-eslint/no-explicit-any await streamPipeline(body as any, createWriteStream(tmpZip)); // Fix #14: zip-slip guard — inspect entries before extraction. // `unzip -l` lists entries in a machine-readable format; we scan for ".." or // absolute paths that would escape the destination directory. try { const { stdout: listing } = await execFileAsync('unzip', ['-l', tmpZip]); const lines = listing.split('\n'); for (const line of lines) { // Entry lines look like: "