diff --git a/.github/actions/locate-vcvarsall-and-setup-env/action.yml b/.github/actions/locate-vcvarsall-and-setup-env/action.yml new file mode 100644 index 0000000..b2db018 --- /dev/null +++ b/.github/actions/locate-vcvarsall-and-setup-env/action.yml @@ -0,0 +1,63 @@ +name: 'Locate vcvarsall and Setup Environment' +description: 'Locates vcvarsall.bat, sets up the environment, and handles PATH updates.' +inputs: + architecture: + description: 'Target architecture (x64 or x86)' + required: true + default: 'x64' +outputs: + vcvarsall_path: + description: "Path to vcvarsall.bat" + value: ${{ steps.find-vcvarsall.outputs.vcvarsall_path }} +runs: + using: "composite" + steps: + - name: Find vcvarsall.bat + id: find-vcvarsall + shell: python # Use Python shell + run: | + import os + import subprocess + + vswhere_path = os.path.join(os.environ["ProgramFiles(x86)"], "Microsoft Visual Studio", "Installer", "vswhere.exe") + + try: + process = subprocess.run([vswhere_path, "-latest", "-property", "installationPath"], capture_output=True, text=True, check=True) + vs_install_path = process.stdout.strip() + vcvarsall_path = os.path.join(vs_install_path, "VC", "Auxiliary", "Build", "vcvarsall.bat") + + if os.path.exists(vcvarsall_path): + print(f"vcvarsall found at: {vcvarsall_path}") + # Use GITHUB_OUTPUT environment variable + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"vcvarsall_path={vcvarsall_path}\n") + else: + print(f"vcvarsall.bat not found at expected path: {vcvarsall_path}") + # Use 'exit(1)' for Python to properly signal failure to GitHub Actions + exit(1) + + + except subprocess.CalledProcessError as e: + print(f"Error running vswhere.exe: {e}") + print(f"vswhere output: {e.stdout}") + print(f"vswhere stderr: {e.stderr}") + exit(1) # Exit with a non-zero code on error + except FileNotFoundError: + print(f"vswhere.exe not found at: {vswhere_path}") + exit(1) + + + - name: Setup Environment + shell: cmd + run: | + REM Get initial environment variables + set > initial_env.txt + + REM Call vcvarsall.bat using the output from the previous step + call "${{ steps.find-vcvarsall.outputs.vcvarsall_path }}" ${{ inputs.architecture }} + + REM Get environment variables after calling vcvarsall.bat + set > final_env.txt + + REM Call the Python script to update the GitHub Actions environment + python ${{ github.action_path }}\update_environment.py diff --git a/.github/actions/locate-vcvarsall-and-setup-env/locate_vcvarsall.bat b/.github/actions/locate-vcvarsall-and-setup-env/locate_vcvarsall.bat new file mode 100644 index 0000000..df900e5 --- /dev/null +++ b/.github/actions/locate-vcvarsall-and-setup-env/locate_vcvarsall.bat @@ -0,0 +1,30 @@ +@echo off +setlocal + +set vswherepath="%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +set vcvarsall_arch=%1 +if "%vcvarsall_arch%" == "x86" ( + set vcvarsall_arch=x86 +) else ( + set vcvarsall_arch=x64 +) + +for /f "usebackq delims=" %%i in (`%vswherepath% -latest -property installationPath`) do ( + if exist "%%i\VC\Auxiliary\Build\vcvars%vcvarsall_arch%.bat" ( + set "vcvarsall=%%i\VC\Auxiliary\Build\vcvars%vcvarsall_arch%.bat" + ) +) + +echo "Get initial environment variables" +set > initial_env.txt + +echo "Call vcvarsall.bat" +call "%vcvarsall%" + +echo "Get environment variables after calling vcvarsall.bat" +set > final_env.txt + +echo "Call the Python script to update the GitHub Actions environment" +python "%~dp0\update_environment.py" + +endlocal \ No newline at end of file diff --git a/.github/actions/locate-vcvarsall-and-setup-env/update_environment.py b/.github/actions/locate-vcvarsall-and-setup-env/update_environment.py new file mode 100644 index 0000000..9b63b26 --- /dev/null +++ b/.github/actions/locate-vcvarsall-and-setup-env/update_environment.py @@ -0,0 +1,37 @@ +import os +import re + + +def read_env_file(filepath): + env_vars = {} + with open(filepath) as f: + for line in f: + match = re.match(r"^(.*?)=(.*)$", line.strip()) + if match: + env_vars[match.group(1).upper()] = match.group(2) + return env_vars + + +initial_env = read_env_file("initial_env.txt") +final_env = read_env_file("final_env.txt") + +for key, value in final_env.items(): + if key not in initial_env or initial_env[key] != value: + if key.startswith("_"): + continue + if key.upper() == "PATH": + new_paths = value.split(";") + initial_paths = initial_env.get("PATH", "").split(";") + added_paths = [p for p in new_paths if p not in initial_paths and p] + + if added_paths: + print("Adding paths") + with open(os.environ["GITHUB_PATH"], "a") as f: + for path in added_paths: + print(f"Adding PATH: {path}") + f.write(path + os.linesep) + else: + # Use GITHUB_ENV + with open(os.environ["GITHUB_ENV"], "a") as f: + print(f"Setting {key}={value}\n") + f.write(f"{key}={value}\n") diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a57fdfc..a0577f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,3 @@ -# .github/workflows/build.yml name: Build Actions Check on: @@ -9,8 +8,8 @@ on: workflow_dispatch: jobs: - build: - name: Build & Lint JavaScript Actions + linux-ci: + name: Linux CI runs-on: ubuntu-latest steps: @@ -34,3 +33,120 @@ jobs: - name: Run Unit Tests with Jest run: npm test + + - name: Test setup-cmake action (Linux) + uses: ./build/setup-cmake + with: + cmake-version: '4.0.0' + cmake-hash: '8482e754bf5bf45349ba2f2184999f81f8754ed3d281e1708f1f9a3b2fcd05c3aa5368e6247930495722ffc5982aadbe489630c5716241ab1702c3cf866483cf' + add-to-path: 'true' + disable-terrapin: 'true' + + windows-ci-no-downloader: + name: Windows CI (No Downloader Tool) + runs-on: windows-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install Dependencies + # npm ci should work fine on Windows + run: npm ci + + - name: Lint Source Code + # Assumes your lint script (ESLint) works cross-platform + run: npm run lint + + - name: Build Actions using esbuild + # Assumes your build script (esbuild) works cross-platform + # This builds the JS code (e.g., dist/index.js) needed by the action + run: npm run build + + - name: Run Unit Tests with Jest + # Assumes your Jest tests work cross-platform + run: npm test + + - name: Test setup-cmake action (Windows) + uses: ./build/setup-cmake + with: + cmake-version: '4.0.0' + cmake-hash: '704fc67b9efa1d65e68a516b439ce3f65f1d6388dc0794002a342d4b59cd3ea63619e674d0343a08c03e9831b053bcbb3ae7635ac42f7796b8d440deeb81b7f6' + add-to-path: 'true' + disable-terrapin: 'true' + + - name: Verify CMake Installation (Windows) + run: cmake --version + + windows-ci: + name: Windows CI + runs-on: windows-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install Dependencies + # npm ci should work fine on Windows + run: npm ci + + - name: Lint Source Code + # Assumes your lint script (ESLint) works cross-platform + run: npm run lint + + - name: Build Actions using esbuild + # Assumes your build script (esbuild) works cross-platform + # This builds the JS code (e.g., dist/index.js) needed by the action + run: npm run build + + - name: Run Unit Tests with Jest + # Assumes your Jest tests work cross-platform + run: npm test + + - name: Locate vcvarsall and Setup Env + uses: ./.github/actions/locate-vcvarsall-and-setup-env + with: + architecture: x64 + + - name: Compile terrapin_tool.exe + shell: cmd # Use cmd shell, often better set up for cl.exe + run: | + echo "Navigating to C++ source directory..." + cd "${{ github.workspace }}\test\cpp" + IF %ERRORLEVEL% NEQ 0 ( + echo "Error: Failed to change directory to test\cpp" + exit /B 1 + ) + + echo "Compiling main.cpp..." + cl.exe /nologo /EHsc /std:c++20 /MD /W4 /DUNICODE /D_UNICODE main.cpp /Fe:terrapin_tool.exe winhttp.lib bcrypt.lib shell32.lib + IF %ERRORLEVEL% NEQ 0 ( + echo "Error: C++ compilation failed." + exit /B 1 + ) + + echo "Compilation successful. Listing directory contents:" + dir terrapin_tool.exe + + - name: Test setup-cmake action (Windows) + uses: ./build/setup-cmake + with: + cmake-version: '4.0.0' + cmake-hash: '704fc67b9efa1d65e68a516b439ce3f65f1d6388dc0794002a342d4b59cd3ea63619e674d0343a08c03e9831b053bcbb3ae7635ac42f7796b8d440deeb81b7f6' + add-to-path: 'true' + terrapin-tool-path: ${{ github.workspace }}/test/cpp/terrapin_tool.exe + + - name: Verify CMake Installation (Windows) + run: cmake --version \ No newline at end of file diff --git a/actions/setup-cmake/README.md b/actions/setup-cmake/README.md new file mode 100644 index 0000000..2b0d006 --- /dev/null +++ b/actions/setup-cmake/README.md @@ -0,0 +1,61 @@ +# ./actions/setup-cmake/README.md + +# Setup CMake Action + +This action downloads, verifies (if hash provided), extracts, caches, and optionally adds a specific version of CMake to the system's PATH. + +## Features + +* Downloads a specific CMake version (e.g., `4.0.0`) or the `"latest"` available release. +* Supports Windows, Linux, and macOS runners. +* Supports common architectures like `x86_64` (x64) and `aarch64` (arm64), using `universal` for modern macOS builds. +* Verifies the download using a provided SHA512 hash **(optional)**. +* Integrates with the GitHub Actions tool cache (`@actions/tool-cache`) for efficiency across workflow runs. +* Optionally uses the `TerrapinRetrievalTool` for downloads (primarily for Windows environments where it's available *and* a hash is provided). +* Optionally adds the CMake `bin` directory to the `PATH` environment variable (default: true). +* Outputs the root installation path and the path to the `bin` directory. + +## Inputs + +* `cmake-version`: **(Required)** The CMake version to download (e.g., `4.0.0`) or the string `"latest"`. +* `cmake-hash`: (**Optional**) The expected SHA512 hash (hex) of the CMake archive for the target platform/architecture. + * If provided, the download integrity will be verified using this hash. Terrapin (if enabled/available) will also use this hash. + * **If omitted, hash verification is skipped, and the Terrapin tool will not be used.** + * **⚠️ SECURITY WARNING:** Omitting the hash significantly increases the risk of supply chain attacks, as the integrity of the downloaded CMake artifact will not be checked. It is **strongly recommended** to provide a hash whenever possible, especially for production workflows. Pinning to a specific `cmake-version` and providing its corresponding hash is the most secure approach. +* `terrapin-tool-path`: (**Optional**) Path to the `TerrapinRetrievalTool.exe` executable. Defaults to `C:/local/Terrapin/TerrapinRetrievalTool.exe`. Only used on Windows if `disable-terrapin` is `false`, the tool exists, **and `cmake-hash` is provided**. +* `disable-terrapin`: (**Optional**) Boolean. Set to `'true'` to force direct download via `@actions/tool-cache` instead of attempting to use Terrapin. Defaults to `'false'`. +* `add-to-path`: (**Optional**) Boolean. Indicates whether to add the CMake `bin` directory to the `PATH` environment variable. Defaults to `'true'`. Set to `'false'` if you only need to download/cache CMake or want to manage the PATH manually in subsequent steps. + +## Outputs + +* `cmake-root`: The absolute path to the root directory of the cached CMake installation (e.g., `/_work/_tool/cmake/4.0.0/x64/cmake-4.0.0-linux-x86_64`). Useful for referencing CMake modules or other files within the installation. +* `cmake-path`: The absolute path to the directory containing the CMake executables (e.g., `/_work/_tool/cmake/4.0.0/x64/cmake-4.0.0-linux-x86_64/bin`). This path is added to the system `PATH` environment variable only if the `add-to-path` input is `true` (the default). + +## Example Usage + +### Recommended: Pin Version with Hash (Adds to PATH) + +```yaml +name: Build with Pinned CMake + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest # Or windows-latest, macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup CMake 4.0.0 (Secure, Adds to PATH) + uses: microsoft/onnxruntime-github-actions/actions/setup-cmake@main + with: + cmake-version: '4.0.0' + cmake-hash: 'example_linux_x64_sha512_hash_here_replace_me' + # add-to-path: 'true' # This is the default + + - name: Verify CMake and Build + run: | + cmake --version # Should work as it's in PATH + cmake -B build . + cmake --build build \ No newline at end of file diff --git a/actions/setup-cmake/action.yml b/actions/setup-cmake/action.yml new file mode 100644 index 0000000..d53a93c --- /dev/null +++ b/actions/setup-cmake/action.yml @@ -0,0 +1,38 @@ +name: 'Setup CMake' +description: 'Downloads, verifies (if hash provided), extracts, caches, and optionally adds a specific version of CMake to the PATH.' # Updated description + +inputs: + cmake-version: + description: 'The CMake version to download (e.g., 3.28.3) or "latest".' + required: true + cmake-hash: + description: > + Optional. The expected SHA512 hash of the CMake archive for the target platform. + If provided, the download integrity will be verified. + If omitted, hash verification is skipped, and Terrapin tool (if available) will not be used. + Omitting the hash increases security risks (supply chain attacks). + required: false + terrapin-tool-path: + description: 'Path to the TerrapinRetrievalTool.exe executable (if used).' + required: false + default: 'C:/local/Terrapin/TerrapinRetrievalTool.exe' + disable-terrapin: + description: 'Set to true to bypass Terrapin and download directly.' + required: false + default: 'false' + add-to-path: + description: 'If true, add the CMake bin directory to the PATH environment variable.' + required: false + default: 'true' + +outputs: + cmake-root: + description: 'The root directory path of the cached CMake installation.' + cmake-path: + description: > + The path to the directory containing the CMake executables (e.g., /path/to/cmake/bin). + This path is added to the system PATH only if `add-to-path` is true. + +runs: + using: 'node20' + main: 'dist/index.js' \ No newline at end of file diff --git a/src/setup-cmake/index.js b/src/setup-cmake/index.js new file mode 100644 index 0000000..a8486bd --- /dev/null +++ b/src/setup-cmake/index.js @@ -0,0 +1,325 @@ +const core = require('@actions/core'); +const tc = require('@actions/tool-cache'); +const exec = require('@actions/exec'); +const github = require('@actions/github'); // To resolve 'latest' +const crypto = require('node:crypto'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); + +/** + * Verifies the SHA512 hash of a downloaded file. + * @param {string} filePath - Path to the file to verify. + * @param {string} expectedHash - The expected SHA512 hash in hex format. + * @returns {Promise} - True if the hash matches, false otherwise. + */ +async function verifySHA512(filePath, expectedHash) { + return new Promise((resolve, reject) => { + core.info(`Calculating SHA512 for file: ${filePath}`); + const hash = crypto.createHash('sha512'); + const stream = fs.createReadStream(filePath); + stream.on('error', err => reject(err)); + stream.on('data', chunk => hash.update(chunk)); + stream.on('end', () => { + const actualHash = hash.digest('hex'); + core.debug(`Actual SHA512: ${actualHash}`); // Use debug for potentially long hashes + core.debug(`Expected SHA512: ${expectedHash}`); + resolve(actualHash.toLowerCase() === expectedHash.toLowerCase()); + }); + }); +} + +/** + * Gets the runner platform identifier used by CMake release artifacts. + * @returns {string} 'windows', 'linux', or 'macos' + */ +function getPlatformIdentifier() { + const platform = process.platform; + if (platform === 'win32') return 'windows'; + if (platform === 'linux') return 'linux'; + if (platform === 'darwin') return 'macos'; + throw new Error(`Unsupported platform: ${platform}`); +} + +/** + * Gets the runner architecture identifier used by CMake release artifacts. + * @returns {string} e.g., 'x86_64', 'aarch64', 'universal' (for macOS) + */ +function getArchIdentifier() { + const arch = process.arch; + const platform = process.platform; + + if (platform === 'darwin') { + // CMake uses 'universal' for macOS releases covering x64 and arm64 on newer versions + // Older versions might have used arch-specific names or different universal markers. + // Sticking with 'universal' as the likely modern choice. + return 'universal'; + } + if (arch === 'x64') return 'x86_64'; + if (arch === 'arm64') return 'aarch64'; + // Add more specific architecture handling if needed (e.g., 32-bit) + // if (platform === 'windows' && arch === 'ia32') return 'win32-x86'; + throw new Error(`Unsupported architecture: ${arch} on platform: ${platform}`); +} + +/** + * Resolves 'latest' to a specific CMake version using the GitHub API. + * @returns {Promise} The latest CMake version string (e.g., '3.28.3'). + */ +async function getLatestCMakeVersion() { + core.info('Querying GitHub API for the latest CMake release...'); + // Requires GITHUB_TOKEN to be available in the environment + const token = core.getInput('github-token') || process.env.GITHUB_TOKEN; + if (!token) { + throw new Error('GitHub token not found. Cannot query for the latest release. Provide github-token input or ensure GITHUB_TOKEN env var is set.'); + } + const octokit = github.getOctokit(token); + try { + const latestRelease = await octokit.rest.repos.getLatestRelease({ + owner: 'Kitware', + repo: 'CMake', + }); + const tagName = latestRelease.data.tag_name; // e.g., "v3.28.3" + if (!tagName || !tagName.startsWith('v')) { + throw new Error(`Unexpected tag name format for latest release: ${tagName}`); + } + const version = tagName.substring(1); // Remove leading 'v' + core.info(`Latest CMake version found: ${version}`); + return version; + } catch (error) { + core.error(`Failed to fetch latest CMake release: ${error.message}`); + if (error.status === 403 || error.status === 401) { + core.warning('GitHub API rate limit likely exceeded or token missing/invalid. Cannot resolve "latest".'); + } + throw error; // Re-throw to fail the action + } +} + +async function run() { + try { + // --- Get Inputs --- + const cmakeVersionInput = core.getInput('cmake-version', { required: true }); + const cmakeHash = core.getInput('cmake-hash'); // Optional + const terrapinPath = core.getInput('terrapin-tool-path'); + const disableTerrapin = core.getBooleanInput('disable-terrapin'); + const addToPath = core.getBooleanInput('add-to-path'); // Defaults to true based on action.yml + + const hasHash = cmakeHash && cmakeHash.trim() !== ''; // Check if hash is provided and not empty/whitespace + + const toolName = 'cmake'; + const platform = getPlatformIdentifier(); + const arch = getArchIdentifier(); + + // --- Resolve Version --- + let resolvedCmakeVersion = cmakeVersionInput; + if (cmakeVersionInput.toLowerCase() === 'latest') { + if (!hasHash) { + core.warning('Resolving "latest" CMake version without a `cmake-hash`. The downloaded artifact will not be verified.'); + } else { + core.warning('Resolving "latest" CMake version. Ensure the provided `cmake-hash` corresponds to the actual latest version for this platform/architecture.'); + } + resolvedCmakeVersion = await getLatestCMakeVersion(); + } + core.info(`Setting up CMake version: ${resolvedCmakeVersion} for ${platform}-${arch}`); + + // --- Cache Check --- + // Include platform and arch in the cache key for uniqueness + const cacheKeyArch = `${platform}-${arch}`; + let cmakeRootPath = tc.find(toolName, resolvedCmakeVersion, cacheKeyArch); + + if (cmakeRootPath) { + core.info(`Found cached CMake at: ${cmakeRootPath}`); + } else { + // --- Cache Miss --- + core.info(`CMake version ${resolvedCmakeVersion} (${cacheKeyArch}) not found in cache. Downloading...`); + + // --- Determine Download URL and File Info --- + const fileExtension = platform === 'windows' ? 'zip' : 'tar.gz'; + let fileName = `cmake-${resolvedCmakeVersion}-${platform}-${arch}`; + + // Special case for macOS universal build name convention + if (platform === 'macos' && arch === 'universal') { + fileName = `cmake-${resolvedCmakeVersion}-macos-universal`; + } + // Add other platform/arch specific filename adjustments if needed + + const archiveFileName = `${fileName}.${fileExtension}`; + const downloadUrl = `https://github.com/Kitware/CMake/releases/download/v${resolvedCmakeVersion}/${archiveFileName}`; + core.info(`Download URL: ${downloadUrl}`); + + // --- Determine Download Method --- + // Terrapin can only be used if enabled, on Windows, tool exists, AND a hash is provided. + const canUseTerrapin = !disableTerrapin && platform === 'windows' && fs.existsSync(terrapinPath); + const useTerrapin = canUseTerrapin && hasHash; // Terrapin requires hash + + // Logging about download method decision + if (useTerrapin) { + core.info(`Using Terrapin Tool at: ${terrapinPath} (hash provided).`); + } else { + if (!disableTerrapin && platform === 'windows' && !fs.existsSync(terrapinPath)) { + core.info(`Terrapin retrieval tool not found at '${terrapinPath}'. Using direct download.`); + } else if (disableTerrapin){ + core.info('Terrapin download explicitly disabled. Using direct download.'); + } else if (canUseTerrapin && !hasHash) { + core.warning(`Terrapin is available but will not be used because 'cmake-hash' was not provided. Using direct download.`); + } else if (!canUseTerrapin && platform === 'windows') { + core.info('Using direct download on Windows.'); + } else if (platform !== 'windows') { + core.info('Using direct download (non-Windows platform).'); + } + + // Security warning if hash is missing + if (!hasHash) { + core.warning('SECURITY RISK: `cmake-hash` was not provided. The integrity of the downloaded CMake artifact will NOT be verified.'); + } + } + + // --- Download --- + let downloadedArchivePath; + const runnerTempDir = process.env.RUNNER_TEMP || os.tmpdir(); + if (!runnerTempDir) { + throw new Error('Runner temporary directory environment variable not found.'); + } + const tempDownloadPath = path.join(runnerTempDir, archiveFileName); + + if (useTerrapin) { + // This block is only reached if hasHash is true + const terrapinBaseUrl = 'https://vcpkg.storage.devpackages.microsoft.io/artifacts/'; // Base URL used by Terrapin cache? Confirm if needed. + const terrapinArgs = [ + '-b', terrapinBaseUrl, + '-a', 'true', + '-u', 'Environment', + '-p', downloadUrl, // Actual package URL + '-s', cmakeHash, // SHA512 hash for verification by Terrapin + '-d', tempDownloadPath // Download destination + ]; + await core.group('Downloading CMake via Terrapin Tool', async () => { + await exec.exec(`"${terrapinPath}"`, terrapinArgs); + }); + downloadedArchivePath = tempDownloadPath; + } else { + // Direct download using @actions/tool-cache + await core.group('Downloading CMake directly', async () => { + downloadedArchivePath = await tc.downloadTool(downloadUrl, tempDownloadPath); + }); + } + core.info(`Download completed. Archive at: ${downloadedArchivePath}`); + + // --- Verify Download Happened --- + if (!fs.existsSync(downloadedArchivePath)) { + throw new Error(`Download failed: Expected file not found at ${downloadedArchivePath}`); + } + + // --- Verify SHA512 Hash (Manual Check, only if hash provided) --- + if (hasHash) { + core.info('Verifying SHA512 hash...'); + const hashMatch = await verifySHA512(downloadedArchivePath, cmakeHash); + if (!hashMatch) { + // Clean up potentially compromised file before failing + try { fs.unlinkSync(downloadedArchivePath); } catch (e) { core.warning(`Failed to delete mismatched archive: ${e.message}`); } + core.setFailed('SHA512 hash verification failed! Downloaded file hash does not match expected hash.'); + return; // Stop execution + } + core.info('SHA512 hash verification successful.'); + } else { + // If no hash was provided, explicitly state that verification is skipped + core.info('Skipping SHA512 hash verification as `cmake-hash` was not provided.'); + } + + // --- Extract --- + core.info(`Extracting ${archiveFileName}...`); + let tempExtractPath; + if (fileExtension === 'zip') { + tempExtractPath = await tc.extractZip(downloadedArchivePath); + } else { // tar.gz + tempExtractPath = await tc.extractTar(downloadedArchivePath); + } + core.info(`Extracted archive to temporary location: ${tempExtractPath}`); + + // --- Locate Actual Extracted Directory --- + // CMake archives usually extract into a subdirectory like cmake-3.28.3-windows-x86_64 + const potentialRoot = path.join(tempExtractPath, fileName); // The expected folder name inside the archive + if (fs.existsSync(potentialRoot) && fs.statSync(potentialRoot).isDirectory()) { + cmakeRootPath = potentialRoot; + core.info(`Located extracted CMake root at: ${cmakeRootPath}`); + } else { + // Fallback: If the top level only contains one directory, assume that's it. + const files = fs.readdirSync(tempExtractPath); + const directories = files.filter(f => fs.statSync(path.join(tempExtractPath, f)).isDirectory()); + if (directories.length === 1) { + cmakeRootPath = path.join(tempExtractPath, directories[0]); + core.warning(`Expected folder '${fileName}' not found directly. Assuming single directory '${directories[0]}' is the root: ${cmakeRootPath}`); + } else { + throw new Error(`Could not locate the extracted CMake directory inside ${tempExtractPath}. Contents: ${files.join(', ')}. Expected pattern: ${fileName}`); + } + } + + // --- Clean up downloaded archive --- + try { + fs.unlinkSync(downloadedArchivePath); + core.info(`Cleaned up downloaded archive: ${downloadedArchivePath}`); + } catch (e) { + core.warning(`Failed to delete downloaded archive: ${e.message}`); + } + + + // --- Cache Directory --- + core.info(`Caching CMake directory: ${cmakeRootPath}`); + cmakeRootPath = await tc.cacheDir(cmakeRootPath, toolName, resolvedCmakeVersion, cacheKeyArch); + core.info(`Successfully cached CMake to: ${cmakeRootPath}`); + } // End if (cache miss) + + // --- Add CMake bin to PATH (Conditional) --- + const cmakeBinPath = path.join(cmakeRootPath, 'bin'); + if (!fs.existsSync(cmakeBinPath)) { + // This error is critical regardless of adding to path, so keep it outside the condition + throw new Error(`CMake 'bin' directory not found within the cached/extracted path: ${cmakeBinPath}`); + } + + if (addToPath) { // Check the input value + core.info(`Adding ${cmakeBinPath} to PATH as 'add-to-path' is true.`); + core.addPath(cmakeBinPath); + } else { + core.info(`Skipping adding ${cmakeBinPath} to PATH as 'add-to-path' is false.`); + } + + // --- Set Outputs --- + // Outputs are set regardless of whether it was added to PATH + core.setOutput('cmake-root', cmakeRootPath); + core.setOutput('cmake-path', cmakeBinPath); // Output the path even if not added to env PATH + + // --- Final Logging --- + if (addToPath) { + core.info(`CMake ${resolvedCmakeVersion} setup complete. Executable directory added to PATH.`); + } else { + core.info(`CMake ${resolvedCmakeVersion} setup complete. Executable directory NOT added to PATH (add-to-path: false).`); + } + + // --- Verify Installation (Optional, only if added to PATH) --- + if (addToPath) { + await core.group('Verifying CMake installation via PATH', async () => { + try { + // Execute cmake from the PATH to verify it's found + await exec.exec('cmake', ['--version']); + } catch (error) { + // Log a warning instead of failing the whole action if verification fails + core.warning(`Verification command 'cmake --version' failed: ${error.message}. This might indicate an issue with the PATH or the CMake executable itself.`); + } + }); + } else { + core.info('Skipping CMake verification via PATH as add-to-path is false.'); + } + + } catch (error) { + // Catch errors from any step and fail the action + core.setFailed(error.message); + } +} + +// Run the action if invoked directly +if (require.main === module) { + run(); +} + +// Export run function for testing or programmatic use +module.exports = { run, verifySHA512, getPlatformIdentifier, getArchIdentifier, getLatestCMakeVersion }; \ No newline at end of file diff --git a/test/cpp/build.bat b/test/cpp/build.bat new file mode 100644 index 0000000..a483917 --- /dev/null +++ b/test/cpp/build.bat @@ -0,0 +1 @@ +cl.exe /nologo /EHsc /std:c++20 /MD /W4 /DUNICODE /D_UNICODE main.cpp /Fe:terrapin_tool.exe winhttp.lib bcrypt.lib shell32.lib \ No newline at end of file diff --git a/test/cpp/main.cpp b/test/cpp/main.cpp new file mode 100644 index 0000000..87dc07d --- /dev/null +++ b/test/cpp/main.cpp @@ -0,0 +1,324 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include // For CommandLineToArgvW +#include +#include +#include +#include +// #include // No longer explicitly needed for fallback hex formatting +#include // Keep for potential implicit needs or other formatting +#include // For unique_ptr +#include // For runtime_error +#include // For path +#include // For span (C++20) +#include // For format (C++20) + +// Linker dependencies +#pragma comment(lib, "winhttp.lib") +#pragma comment(lib, "bcrypt.lib") +#pragma comment(lib, "shell32.lib") + +// --- Custom Exception for Windows API Errors --- +class WindowsException : public std::runtime_error { +private: + DWORD errorCode_; + std::wstring functionName_; + std::string formattedMessage_; // Store formatted message + + static std::string FormatWindowsError(DWORD errorCode, const std::wstring& functionName) { + LPWSTR lpMsgBuf = nullptr; + DWORD bufLen = FormatMessageW( + FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + NULL, + errorCode, + MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + (LPWSTR)&lpMsgBuf, + 0, NULL); + + std::string message; + if (bufLen > 0 && lpMsgBuf) { + int narrowLen = WideCharToMultiByte(CP_ACP, 0, lpMsgBuf, bufLen, NULL, 0, NULL, NULL); + if (narrowLen > 0) { + std::vector narrowBuf(narrowLen); + WideCharToMultiByte(CP_ACP, 0, lpMsgBuf, bufLen, narrowBuf.data(), narrowLen, NULL, NULL); + message = std::string(narrowBuf.data(), narrowLen); + while (!message.empty() && (message.back() == '\r' || message.back() == '\n')) { + message.pop_back(); + } + } else { + message = "(WideCharToMultiByte failed)"; + } + LocalFree(lpMsgBuf); + } else { + message = "(FormatMessage failed)"; + } + + try { + std::wstring wFunctionName = functionName; + int fnNarrowLen = WideCharToMultiByte(CP_ACP, 0, wFunctionName.c_str(), -1, NULL, 0, NULL, NULL); + std::string fnNarrow(fnNarrowLen, 0); + WideCharToMultiByte(CP_ACP, 0, wFunctionName.c_str(), -1, &fnNarrow[0], fnNarrowLen, NULL, NULL); + fnNarrow.pop_back(); // Remove null terminator + + return std::format("Error in {}: {} (Code: {})", fnNarrow, message, errorCode); + } + catch (const std::exception& fmtEx) { + std::ostringstream oss; // Keep sstream include for this rare fallback case + oss << "Error in function (std::format failed during exception formatting: " << fmtEx.what() << "): " << message << " (Code: " << errorCode << ")"; + return oss.str(); + } + } + +public: + WindowsException(DWORD errorCode, const std::wstring& functionName) + : std::runtime_error(FormatWindowsError(errorCode, functionName)), + errorCode_(errorCode), + functionName_(functionName), + formattedMessage_(what()) + {} + + DWORD getErrorCode() const noexcept { return errorCode_; } + const std::wstring& getFunctionName() const noexcept { return functionName_; } + const char* what() const noexcept override { return formattedMessage_.c_str(); } +}; + + +// --- RAII Deleters --- +struct WinHttpHandleDeleter { void operator()(HINTERNET handle) const { if (handle) WinHttpCloseHandle(handle); } }; +struct HandleDeleter { void operator()(HANDLE handle) const { if (handle && handle != INVALID_HANDLE_VALUE) CloseHandle(handle); } }; +struct BcryptAlgHandleDeleter { void operator()(BCRYPT_ALG_HANDLE handle) const { if (handle) BCryptCloseAlgorithmProvider(handle, 0); } }; +struct BcryptHashHandleDeleter { void operator()(BCRYPT_HASH_HANDLE handle) const { if (handle) BCryptDestroyHash(handle); } }; +struct HeapAllocDeleter { void operator()(PVOID ptr) const { if(ptr) HeapFree(GetProcessHeap(), 0, ptr); } }; +struct LocalAllocDeleter { void operator()(HLOCAL ptr) const { if(ptr) LocalFree(ptr); } }; + +// --- unique_ptr Type Aliases --- +using unique_hinternet = std::unique_ptr; +using unique_handle = std::unique_ptr; +using unique_bcrypt_alg_handle = std::unique_ptr; +using unique_bcrypt_hash_handle = std::unique_ptr; +using unique_heap_ptr = std::unique_ptr; +using unique_local_ptr = std::unique_ptr; + + +// --- Error Checking Helpers --- +inline void CheckWindowsError(BOOL success, const std::wstring& functionName) { if (!success) throw WindowsException(GetLastError(), functionName); } +inline void CheckBcryptError(NTSTATUS status, const std::wstring& functionName) { if (!BCRYPT_SUCCESS(status)) throw WindowsException(status, functionName); } + +// --- Helper to convert byte buffer to hex string using std::format --- +std::wstring BytesToHexString(std::span data) { + std::wstring result; + result.reserve(data.size() * 2); // Pre-allocate memory + for (const BYTE byte : data) { + // Format each byte as a two-digit lowercase hex number + result += std::format(L"{:02x}", byte); + } + return result; + // Fallback using stringstream REMOVED +} + +// Case-insensitive wide string comparison (remains the same) +bool AreEqualIgnoreCase(const std::wstring& s1, const std::wstring& s2) { + return CompareStringOrdinal(s1.c_str(), -1, s2.c_str(), -1, TRUE) == CSTR_EQUAL; +} + +// --- Core Functions (Refactored) --- + +// Returns HTTP status code on success, throws on API errors +DWORD DownloadFile(const std::wstring& url, const std::filesystem::path& destinationPath) { + std::wcout << std::format(L"Attempting to download from: {}\n", url); + std::wcout << std::format(L"Saving to: {}\n", destinationPath.wstring()); + + unique_hinternet hSession{ WinHttpOpen(L"TerrapinCppClient/2.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0) }; + CheckWindowsError(hSession != nullptr, L"WinHttpOpen"); + + URL_COMPONENTSW urlComp = { 0 }; + WCHAR szHostName[256] = { 0 }; + WCHAR szUrlPath[2048] = { 0 }; + + urlComp.dwStructSize = sizeof(urlComp); + urlComp.lpszHostName = szHostName; + urlComp.dwHostNameLength = ARRAYSIZE(szHostName); + urlComp.lpszUrlPath = szUrlPath; + urlComp.dwUrlPathLength = ARRAYSIZE(szUrlPath); + + CheckWindowsError(WinHttpCrackUrl(url.c_str(), static_cast(url.length()), 0, &urlComp), L"WinHttpCrackUrl"); + assert(urlComp.nScheme == INTERNET_SCHEME_HTTPS); + unique_hinternet hConnect{ WinHttpConnect(hSession.get(), szHostName, urlComp.nPort, 0) }; + CheckWindowsError(hConnect != nullptr, L"WinHttpConnect"); + + unique_hinternet hRequest{ WinHttpOpenRequest(hConnect.get(), L"GET", szUrlPath, NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, (urlComp.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0) }; + CheckWindowsError(hRequest != nullptr, L"WinHttpOpenRequest"); + + CheckWindowsError(WinHttpSendRequest(hRequest.get(), WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0), L"WinHttpSendRequest"); + + CheckWindowsError(WinHttpReceiveResponse(hRequest.get(), NULL), L"WinHttpReceiveResponse"); + + DWORD dwStatusCode = 0; + DWORD dwSize = sizeof(dwStatusCode); + CheckWindowsError(WinHttpQueryHeaders(hRequest.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &dwStatusCode, &dwSize, WINHTTP_NO_HEADER_INDEX), L"WinHttpQueryHeaders (Status Code)"); + + std::wcout << std::format(L"HTTP Status Code: {}\n", dwStatusCode); + + if (dwStatusCode == HTTP_STATUS_OK) { + unique_handle hFile { CreateFileW(destinationPath.c_str(), GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL) }; + CheckWindowsError(hFile.get() != INVALID_HANDLE_VALUE, L"CreateFileW"); + + std::vector buffer(8192); // 8KB buffer + DWORD dwBytesRead = 0; + DWORD dwBytesWritten = 0; + + while (WinHttpReadData(hRequest.get(), buffer.data(), static_cast(buffer.size()), &dwBytesRead) && dwBytesRead > 0) { + CheckWindowsError(WriteFile(hFile.get(), buffer.data(), dwBytesRead, &dwBytesWritten, NULL), L"WriteFile"); + if (dwBytesRead != dwBytesWritten) { + throw WindowsException(ERROR_WRITE_FAULT, L"WriteFile (Partial Write)"); + } + } + DWORD lastError = GetLastError(); + if (lastError != ERROR_SUCCESS && lastError != ERROR_WINHTTP_CONNECTION_ERROR) { // Re-check error state if WinHttpReadData returned FALSE + CheckWindowsError(FALSE, L"WinHttpReadData (After Loop)"); + } + + std::wcout << L"Download successful.\n"; + } else { + std::wcerr << std::format(L"Download failed: Server returned status {}\n", dwStatusCode); + } + + return dwStatusCode; +} + + +std::wstring CalculateFileSHA512(const std::filesystem::path& filePath) { + std::wcout << std::format(L"Calculating SHA512 hash for: {}\n", filePath.wstring()); + + unique_bcrypt_alg_handle hAlg { nullptr }; + NTSTATUS status = BCryptOpenAlgorithmProvider(reinterpret_cast(&hAlg), BCRYPT_SHA512_ALGORITHM, NULL, 0); + CheckBcryptError(status, L"BCryptOpenAlgorithmProvider"); + + DWORD cbHashObject = 0, cbResult = 0; + status = BCryptGetProperty(hAlg.get(), BCRYPT_OBJECT_LENGTH, (PBYTE)&cbHashObject, sizeof(DWORD), &cbResult, 0); + CheckBcryptError(status, L"BCryptGetProperty (Object Length)"); + + DWORD cbHashValue = 0; + status = BCryptGetProperty(hAlg.get(), BCRYPT_HASH_LENGTH, (PBYTE)&cbHashValue, sizeof(DWORD), &cbResult, 0); + CheckBcryptError(status, L"BCryptGetProperty (Hash Length)"); + + unique_heap_ptr pbHashObject{ (PBYTE)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, cbHashObject) }; + if (!pbHashObject) throw std::bad_alloc(); + + unique_heap_ptr pbHashValue{ (PBYTE)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, cbHashValue) }; + if (!pbHashValue) throw std::bad_alloc(); + + unique_bcrypt_hash_handle hHash { nullptr }; + status = BCryptCreateHash(hAlg.get(), reinterpret_cast(&hHash), pbHashObject.get(), cbHashObject, NULL, 0, 0); + CheckBcryptError(status, L"BCryptCreateHash"); + + unique_handle hFile{ CreateFileW(filePath.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_SEQUENTIAL_SCAN, NULL) }; + CheckWindowsError(hFile.get() != INVALID_HANDLE_VALUE, L"CreateFileW (for hashing)"); + + std::vector buffer(8192); // 8KB read buffer + DWORD dwBytesRead = 0; + while (ReadFile(hFile.get(), buffer.data(), static_cast(buffer.size()), &dwBytesRead, NULL) && dwBytesRead > 0) { + status = BCryptHashData(hHash.get(), buffer.data(), dwBytesRead, 0); + CheckBcryptError(status, L"BCryptHashData"); + } + DWORD readError = GetLastError(); + if (readError != ERROR_SUCCESS && readError != ERROR_HANDLE_EOF) { + throw WindowsException(readError, L"ReadFile (during hashing)"); + } + + status = BCryptFinishHash(hHash.get(), pbHashValue.get(), cbHashValue, 0); + CheckBcryptError(status, L"BCryptFinishHash"); + + std::span hashSpan(pbHashValue.get(), cbHashValue); + std::wstring hashString = BytesToHexString(hashSpan); + + std::wcout << std::format(L"Calculated SHA512: {}\n", hashString); + return hashString; +} + + +int wmain() { + int argc = 0; + unique_local_ptr argv_ptr{ CommandLineToArgvW(GetCommandLineW(), &argc) }; + + if (argv_ptr == nullptr || argc <= 1) { + std::wcerr << L"Error: Failed to get command line arguments or no arguments provided.\n"; + return 1; + } + + std::vector args; + LPWSTR* argv = argv_ptr.get(); + for (int i = 0; i < argc; ++i) { args.emplace_back(argv[i]); } + + + std::wstring baseUrl, auth, user, packageUrl, expectedHash; + std::filesystem::path destPath; + + for (size_t i = 1; i < args.size(); ++i) { + const auto& arg = args[i]; + if ((arg == L"-b" || arg == L"/b") && i + 1 < args.size()) { baseUrl = args[++i]; } + else if ((arg == L"-a" || arg == L"/a") && i + 1 < args.size()) { auth = args[++i]; } + else if ((arg == L"-u" || arg == L"/u") && i + 1 < args.size()) { user = args[++i]; } + else if ((arg == L"-p" || arg == L"/p") && i + 1 < args.size()) { packageUrl = args[++i]; } + else if ((arg == L"-s" || arg == L"/s") && i + 1 < args.size()) { expectedHash = args[++i]; } + else if ((arg == L"-d" || arg == L"/d") && i + 1 < args.size()) { destPath = args[++i]; } + else { std::wcerr << std::format(L"Warning: Ignoring unrecognized or incomplete argument: {}\n", arg); } + } + + if (packageUrl.empty() || expectedHash.empty() || destPath.empty()) { + std::wcerr << L"Error: Missing required arguments.\n" + << L"Usage: -p -s -d [optional_args]\n"; + return 1; + } + + std::wcout << L"--- Configuration ---\n"; + std::wcout << std::format(L"Package URL (-p): {}\n", packageUrl); + std::wcout << std::format(L"Expected SHA512 (-s): {}\n", expectedHash); + std::wcout << std::format(L"Destination Path (-d): {}\n", destPath.wstring()); + std::wcout << std::format(L"Base URL (-b): {} (Ignored)\n", baseUrl.empty() ? L"[Not Provided]" : baseUrl); + std::wcout << std::format(L"Auth (-a): {} (Ignored)\n", auth.empty() ? L"[Not Provided]" : auth); + std::wcout << std::format(L"User (-u): {} (Ignored)\n", user.empty() ? L"[Not Provided]" : user); + std::wcout << L"--------------------\n"; + + int exitCode = 1; + try { + DWORD httpStatus = DownloadFile(packageUrl, destPath); + + if (httpStatus != HTTP_STATUS_OK) { + std::wcerr << L"Download step did not complete successfully (HTTP Status: " << httpStatus << L").\n"; + return 1; + } + + std::wstring calculatedHash = CalculateFileSHA512(destPath); + + std::wcout << L"Comparing Hashes...\n"; + std::wcout << std::format(L" Expected: {}\n", expectedHash); + std::wcout << std::format(L" Actual: {}\n", calculatedHash); + + if (AreEqualIgnoreCase(expectedHash, calculatedHash)) { + std::wcout << L"SUCCESS: SHA512 hash verification passed.\n"; + exitCode = 0; + } else { + std::wcerr << L"ERROR: SHA512 hash verification FAILED!\n"; + exitCode = 2; + } + } + catch (const WindowsException& ex) { + std::wcerr << L"Operation failed due to Windows API error:\n" << ex.what() << std::endl; + exitCode = 1; + } + catch (const std::bad_alloc& ex) { + std::wcerr << L"Operation failed due to memory allocation error: " << ex.what() << std::endl; + exitCode = 1; + } + catch (const std::exception& ex) { + std::wcerr << L"Operation failed due to an unexpected error: " << ex.what() << std::endl; + exitCode = 1; + } + + return exitCode; +} \ No newline at end of file