Add support for pinning tools to a checksum (#1098)

Tools in the tools input can now be pinned to a checksum using the
tool:version@sha256:<hash> or tool:version@sha512:<hash> syntax.
The downloaded tool is verified against the checksum on all platforms,
including when it is served from the tools cache, and it is removed
along with its cache entry if the verification fails.

Checksum verification is supported for tools downloaded as phar
archives. Specifying a checksum for tools set up using composer
packages or custom package scripts results in an error.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Keisuke Maeda
2026-07-23 21:45:09 +09:00
committed by Shivam Mathur
parent b9ef68088b
commit 8ca9579834
7 changed files with 225 additions and 16 deletions
+24 -1
View File
@@ -229,8 +229,18 @@ Function Add-Tool() {
[Parameter(Position = 2, Mandatory = $false)]
$ver_param,
[Parameter(Position = 3, Mandatory = $false)]
$skip_composer_github_auth
$skip_composer_github_auth,
[Parameter(Position = 4, Mandatory = $false)]
$checksum
)
$checksum_regex = '^sha(256|512):[0-9a-fA-F]+$'
if("$ver_param" -match $checksum_regex) {
$checksum = $ver_param
$ver_param = $null
} elseif("$skip_composer_github_auth" -match $checksum_regex) {
$checksum = $skip_composer_github_auth
$skip_composer_github_auth = $null
}
if($tool -eq "composer") {
$script:skip_composer_github_auth = $skip_composer_github_auth -eq 'true'
}
@@ -277,6 +287,19 @@ Function Add-Tool() {
Remove-Item $backup_path -Force -ErrorAction SilentlyContinue
}
if($checksum -and ($status_code -eq 200) -and (Test-Path $tool_path)) {
$checksum_parts = $checksum -split ':'
$actual_checksum = (Get-FileHash -Path $tool_path -Algorithm $checksum_parts[0]).Hash
if($actual_checksum -ne $checksum_parts[1]) {
Remove-Item @($tool_path, $cache_path) -Force -ErrorAction SilentlyContinue
if($tool -eq "composer") {
$env:fail_fast = 'true'
}
Add-Log $cross $tool "Checksum verification failed for $tool"
return
}
}
$escaped_tool = [regex]::Escape($tool)
if (((Get-ChildItem -Path $bin_dir/* | Where-Object Name -Match "^$escaped_tool(\.exe|\.phar)?$").Count -gt 0)) {
$bat_content = @()
+14 -1
View File
@@ -201,8 +201,15 @@ add_tool() {
url=$1
tool=$2
ver_param=$3
checksum=
local arg
for arg in "$@"; do
[[ "$arg" =~ ^sha(256|512):[0-9a-fA-F]+$ ]] && checksum="$arg"
done
[[ "$ver_param" =~ ^sha(256|512): ]] && ver_param=
if [ "$tool" = "composer" ]; then
skip_composer_github_auth="${4:-false}"
[[ "$skip_composer_github_auth" =~ ^sha(256|512): ]] && skip_composer_github_auth=false
fi
tool_path="$tool_path_dir/$tool"
if ! [ -d "$tool_path_dir" ]; then
@@ -235,6 +242,10 @@ add_tool() {
fi
sudo rm -f "$tool_path.bak"
fi
if [ "$status_code" = "200" ] && [ -n "$checksum" ] && ! verify_checksum "$tool_path" "$checksum"; then
sudo rm -f "$tool_path" "$cache_path"
status_code="checksum_mismatch"
fi
if [ "$status_code" = "200" ]; then
add_tools_helper "$tool"
tool_version=$(get_tool_version "$tool" "$ver_param")
@@ -244,7 +255,9 @@ add_tool() {
if [ "$tool" = "composer" ]; then
export fail_fast=true
fi
if [ "$status_code" = "404" ]; then
if [ "$status_code" = "checksum_mismatch" ]; then
add_log "$cross" "$tool" "Checksum verification failed for $tool"
elif [ "$status_code" = "404" ]; then
add_log "$cross" "$tool" "Failed to download $tool from ${url[*]}"
else
add_log "$cross" "$tool" "Could not setup $tool"
+18
View File
@@ -122,6 +122,24 @@ get_sha256() {
fi
}
# Function to verify the checksum of a file.
# checksum format: sha256:<hash> or sha512:<hash>
verify_checksum() {
local file_path=$1
local checksum=$2
local algo="${checksum%%:*}"
local expected="${checksum#*:}"
local actual=
if command -v "${algo}sum" >/dev/null; then
actual="$(sudo "${algo}sum" "$file_path" | cut -d' ' -f1)"
elif command -v shasum >/dev/null; then
actual="$(sudo shasum -a "${algo#sha}" "$file_path" | cut -d' ' -f1)"
elif command -v openssl >/dev/null; then
actual="$(sudo openssl dgst -"$algo" "$file_path" | awk '{print $NF}')"
fi
[ -n "$actual" ] && [ "$(echo "$actual" | tr '[:upper:]' '[:lower:]')" = "$(echo "$expected" | tr '[:upper:]' '[:lower:]')" ]
}
# Function to download a file using cURL.
# mode: -s pipe to stdout, -v save file and return status code
# execute: -e save file as executable
+69 -2
View File
@@ -27,6 +27,7 @@ type ToolFunction =
export interface ToolData {
tool: string;
version: string;
checksum?: string;
os: string;
php_version: string;
github: string;
@@ -73,6 +74,38 @@ interface ToolConfig {
packagist?: string;
}
/**
* Regex to match a checksum suffix in a tool release - tool:version@sha256:<hash>
*/
const checksum_suffix_regex = /@(sha256|sha512):([^@]*)$/;
/**
* Function to parse the checksum suffix in the tool release
*
* @param release
*/
export function extractChecksum(release: string): {
release: string;
checksum?: string;
error?: string;
} {
const matches = release.match(checksum_suffix_regex);
if (!matches) {
return {release};
}
release = release.slice(0, -matches[0].length);
const algo = matches[1];
const hash = matches[2].toLowerCase();
const hash_length = algo === 'sha256' ? 64 : 128;
if (!new RegExp(`^[a-f0-9]{${hash_length}}$`).test(hash)) {
return {
release,
error: `Invalid ${algo} checksum, expected ${hash_length} hexadecimal characters`
};
}
return {release, checksum: `${algo}:${hash}`};
}
/**
* GitHub reference object from API response
*/
@@ -260,7 +293,9 @@ export async function filterList(tools_list: string[]): Promise<string[]> {
const regex_any = /^composer($|:.*)/;
const regex_valid =
/^composer:?($|preview$|snapshot$|v?\d+(\.\d+)?$|v?\d+\.\d+\.\d+[\w-]*$)/;
const matches: string[] = tools_list.filter(tool => regex_valid.test(tool));
const matches: string[] = tools_list.filter(tool =>
regex_valid.test(tool.replace(checksum_suffix_regex, ''))
);
let composer = 'composer';
tools_list = tools_list.filter(tool => !regex_any.test(tool));
switch (true) {
@@ -335,7 +370,8 @@ export async function getPharUrl(data: ToolData): Promise<string> {
export async function addArchive(data: ToolData): Promise<string> {
return (
(await utils.getCommand(data.os, 'tool')) +
(await utils.joins(data.url, data.tool, data.version_parameter))
(await utils.joins(data.url, data.tool, data.version_parameter)) +
(data.checksum ? ' ' + data.checksum : '')
);
}
@@ -612,6 +648,8 @@ export async function getData(
const json_file: string = fs.readFileSync(json_file_path, 'utf8');
const json_objects: Record<string, ToolConfig> = JSON.parse(json_file);
release = release.replace(/\s+/g, '');
const checksum_data = extractChecksum(release);
release = checksum_data.release;
const parts: string[] = release.split(':');
const tool = parts[0];
const version = parts[1];
@@ -663,6 +701,8 @@ export async function getData(
function: config.function,
alias: config.alias
};
data.checksum = checksum_data.checksum;
data.error = checksum_data.error;
data.release = await getRelease(release, data);
data.version = version
? await getVersion(version, data)
@@ -688,6 +728,25 @@ export const functionRecord: Record<
wp_cli: addWPCLI
};
/**
* Function to check if a tool supports checksum verification
*
* Only tools downloaded as archives via add_tool support it, so composer
* packages and tools set up using custom package scripts do not.
*
* @param data
*/
export async function supportsChecksum(data: ToolData): Promise<boolean> {
switch (data.type) {
case 'phar':
return true;
case 'custom-function':
return !['pecl', 'dev_tools'].includes(data.function ?? '');
default:
return false;
}
}
/**
* Setup tools
*
@@ -714,6 +773,14 @@ export async function addTools(
case data.error !== undefined:
script += await utils.addLog('$cross', data.tool, data.error, data.os);
break;
case data.checksum !== undefined && !(await supportsChecksum(data)):
script += await utils.addLog(
'$cross',
data.tool,
'Checksum verification is not supported for ' + data.tool,
data.os
);
break;
case 'phar' === data.type:
script += await addArchive(data);
break;