ci: verify a pinned sha256 for every toolchain download (#4098)

Fixes #4079
diff --git a/.circleci/config2.yml b/.circleci/config2.yml
index 7cc8848..a539c78 100644
--- a/.circleci/config2.yml
+++ b/.circleci/config2.yml
@@ -12,12 +12,6 @@
             type: string
 
     steps:
-      - run:
-          name: Set toolchain url
-          command: |
-            toolchain_url=$(jq -r '."<< parameters.toolchain >>"' .github/actions/setup_toolchain/toolchain.json)
-            echo "export toolchain_url=$toolchain_url" >> $BASH_ENV
-
       - restore_cache:
           name: Restore Toolchain Cache
           key: deps-<< parameters.toolchain >>-{{ checksum ".github/actions/setup_toolchain/toolchain.json" }}
@@ -30,17 +24,18 @@
             # download if folder does not exist (not cached)
             if [ ! -d ~/cache/<< parameters.toolchain >> ]; then
               mkdir -p ~/cache/<< parameters.toolchain >>
+              fetch=.github/actions/setup_toolchain/download/fetch.sh
               if [[ << parameters.toolchain >> == rx-gcc ]]; then
-                wget --progress=dot:giga $toolchain_url -O toolchain.run
+                $fetch << parameters.toolchain >> toolchain.run
                 chmod +x toolchain.run
                 ./toolchain.run -p ~/cache/<< parameters.toolchain >>/gnurx -y
               elif [[ << parameters.toolchain >> == ft9xx-gcc ]]; then
-                wget --progress=dot:giga $toolchain_url -O ~/cache/<< parameters.toolchain >>/ft9xxtoolchain.deb
+                $fetch << parameters.toolchain >> ~/cache/<< parameters.toolchain >>/ft9xxtoolchain.deb
               elif [[ << parameters.toolchain >> == arm-iar ]]; then
-                wget --progress=dot:giga https://netstorage.iar.com/FileStore/STANDARD/001/003/926/iar-lmsc-tools_1.8_amd64.deb -O ~/cache/<< parameters.toolchain >>/iar-lmsc-tools.deb
-                wget --progress=dot:giga $toolchain_url -O ~/cache/<< parameters.toolchain >>/toolchain.deb
+                $fetch iar-lmsc-tools ~/cache/<< parameters.toolchain >>/iar-lmsc-tools.deb
+                $fetch << parameters.toolchain >> ~/cache/<< parameters.toolchain >>/toolchain.deb
               else
-                wget --progress=dot:giga $toolchain_url -O toolchain.tar.gz
+                $fetch << parameters.toolchain >> toolchain.tar.gz
                 tar -C ~/cache/<< parameters.toolchain >> -xaf toolchain.tar.gz
               fi
             fi
diff --git a/.github/actions/setup_toolchain/action.yml b/.github/actions/setup_toolchain/action.yml
index f78fe4d..fba3bf1 100644
--- a/.github/actions/setup_toolchain/action.yml
+++ b/.github/actions/setup_toolchain/action.yml
@@ -25,7 +25,7 @@
       env:
         TOOLCHAIN: ${{ inputs.toolchain }}
       run: |
-        TOOLCHAIN_URL=$(jq -r --arg tc "$TOOLCHAIN" '.[$tc]' .github/actions/setup_toolchain/toolchain.json)
+        TOOLCHAIN_URL=$(jq -er --arg tc "$TOOLCHAIN" '.[$tc].url' .github/actions/setup_toolchain/toolchain.json)
         echo "toolchain_url=$TOOLCHAIN_URL"
         echo "toolchain_url=$TOOLCHAIN_URL" >> $GITHUB_OUTPUT
       shell: bash
diff --git a/.github/actions/setup_toolchain/download/action.yml b/.github/actions/setup_toolchain/download/action.yml
index 28a8b9f..c85bf57 100644
--- a/.github/actions/setup_toolchain/download/action.yml
+++ b/.github/actions/setup_toolchain/download/action.yml
@@ -17,33 +17,35 @@
       id: cache-toolchain-download
       with:
         path: ~/cache/${{ inputs.toolchain }}
-        key: ${{ runner.os }}-${{ inputs.toolchain }}-${{ inputs.toolchain_url }}
+        # whole manifest: arm-iar also installs the iar-lmsc-tools entry
+        key: ${{ runner.os }}-${{ inputs.toolchain }}-${{ hashFiles('.github/actions/setup_toolchain/toolchain.json') }}
 
     - name: Install Toolchain
       if: steps.cache-toolchain-download.outputs.cache-hit != 'true'
       env:
         TOOLCHAIN: ${{ inputs.toolchain }}
         TOOLCHAIN_URL: ${{ inputs.toolchain_url }}
+        FETCH: .github/actions/setup_toolchain/download/fetch.sh
       run: |
         mkdir -p ~/cache/${TOOLCHAIN}
         FILE_EXT="${TOOLCHAIN_URL##*.}"
 
         if [[ ${TOOLCHAIN} == rx-gcc ]]; then
-          wget --progress=dot:giga ${TOOLCHAIN_URL} -O toolchain.run
+          "$FETCH" "$TOOLCHAIN" toolchain.run
           chmod +x toolchain.run
           ./toolchain.run -p ~/cache/${TOOLCHAIN}/gnurx -y
         elif [[ ${TOOLCHAIN} == ft9xx-gcc ]]; then
-          wget --progress=dot:giga ${TOOLCHAIN_URL} -O ~/cache/${TOOLCHAIN}/ft9xxtoolchain.deb
+          "$FETCH" "$TOOLCHAIN" ~/cache/${TOOLCHAIN}/ft9xxtoolchain.deb
         elif [[ ${TOOLCHAIN} == arm-iar ]]; then
-          wget --progress=dot:giga https://netstorage.iar.com/FileStore/STANDARD/001/003/926/iar-lmsc-tools_1.8_amd64.deb -O ~/cache/${TOOLCHAIN}/iar-lmsc-tools.deb
-          wget --progress=dot:giga ${TOOLCHAIN_URL} -O ~/cache/${TOOLCHAIN}/cxarm.deb
+          "$FETCH" iar-lmsc-tools ~/cache/${TOOLCHAIN}/iar-lmsc-tools.deb
+          "$FETCH" "$TOOLCHAIN" ~/cache/${TOOLCHAIN}/cxarm.deb
         elif [[ ${FILE_EXT} == zip ]]; then
-          curl -L "$TOOLCHAIN_URL" -o toolchain.zip
+          "$FETCH" "$TOOLCHAIN" toolchain.zip
           # Arm's Windows zip has no top-level directory; Setup Toolchain expects one
           unzip -q toolchain.zip -d ~/cache/${TOOLCHAIN}/toolchain
           ~/cache/${TOOLCHAIN}/toolchain/bin/arm-none-eabi-gcc.exe --version
         elif [[ ${FILE_EXT} =~ ^(gz|bz2|xz)$ ]]; then
-          curl -fsSL --proto '=https' --proto-redir '=https' --retry 5 --connect-timeout 30 --speed-limit 1024 --speed-time 60 "$TOOLCHAIN_URL" -o toolchain.tar.${FILE_EXT}
+          "$FETCH" "$TOOLCHAIN" toolchain.tar.${FILE_EXT}
           tar -C ~/cache/${TOOLCHAIN} -xaf toolchain.tar.${FILE_EXT}
         else
           echo "Unsupported toolchain file extension: ${FILE_EXT}"
diff --git a/.github/actions/setup_toolchain/download/fetch.sh b/.github/actions/setup_toolchain/download/fetch.sh
new file mode 100755
index 0000000..b5fbfc2
--- /dev/null
+++ b/.github/actions/setup_toolchain/download/fetch.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Usage: fetch.sh NAME OUT - download toolchain.json's NAME entry over https only and verify its sha256
+set -euo pipefail
+
+name=$1 out=$2
+manifest=$(dirname "$0")/../toolchain.json
+url=$(jq -ej --arg n "$name" '.[$n].url' "$manifest")
+sha256=$(jq -ej --arg n "$name" '.[$n].sha256' "$manifest")
+
+curl -fsSL --proto '=https' --proto-redir '=https' --retry 5 --connect-timeout 30 --speed-limit 1024 --speed-time 60 "$url" -o "$out"
+
+# macOS runners have shasum but no sha256sum
+if command -v sha256sum > /dev/null; then
+  echo "$sha256  $out" | sha256sum -c -
+else
+  echo "$sha256  $out" | shasum -a 256 -c -
+fi
diff --git a/.github/actions/setup_toolchain/toolchain.json b/.github/actions/setup_toolchain/toolchain.json
index ec09a01..fa269d3 100644
--- a/.github/actions/setup_toolchain/toolchain.json
+++ b/.github/actions/setup_toolchain/toolchain.json
@@ -1,12 +1,46 @@
 {
-  "aarch64-gcc": "https://developer.arm.com/-/media/Files/downloads/gnu-a/10.3-2021.07/binrel/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf.tar.xz",
-  "arm-clang": "https://github.com/ARM-software/LLVM-embedded-toolchain-for-Arm/releases/download/release-19.1.1/LLVM-ET-Arm-19.1.1-Linux-x86_64.tar.xz",
-  "arm-gcc": "https://gitlab.arm.com/api/v4/projects/tooling%2Fgnu-toolchains-for-arm/packages/generic/gnu-toolchain/14.2.rel1/arm-gnu-toolchain-14.2.rel1-x86_64-arm-none-eabi.tar.xz",
-  "ft9xx-gcc": "https://github.com/Bridgetek/ft32-toolchain-linux/releases/download/v2.7.6/ft9xxtoolchain_2.7.6_amd64.deb",
-  "arm-gcc-macos-latest": "https://gitlab.arm.com/api/v4/projects/tooling%2Fgnu-toolchains-for-arm/packages/generic/gnu-toolchain/14.2.rel1/arm-gnu-toolchain-14.2.rel1-darwin-arm64-arm-none-eabi.tar.xz",
-  "arm-gcc-windows-latest": "https://gitlab.arm.com/api/v4/projects/tooling%2Fgnu-toolchains-for-arm/packages/generic/gnu-toolchain/14.2.rel1/arm-gnu-toolchain-14.2.rel1-mingw-w64-x86_64-arm-none-eabi.zip",
-  "msp430-gcc": "https://software-dl.ti.com/msp430/msp430_public_sw/mcu/msp430/MSPGCC/9_2_0_0/export/msp430-gcc-9.2.0.50_linux64.tar.bz2",
-  "riscv-gcc": "https://github.com/xpack-dev-tools/riscv-none-elf-gcc-xpack/releases/download/v13.2.0-2/xpack-riscv-none-elf-gcc-13.2.0-2-linux-x64.tar.gz",
-  "rx-gcc": "https://github.com/hathach/rx_device/releases/download/0.0.1/gcc-8.3.0.202411-GNURX-ELF.run",
-  "arm-iar": "https://netstorage.iar.com/FileStore/STANDARD/001/003/723/cxarm-9.70.1.deb"
+  "aarch64-gcc": {
+    "url": "https://developer.arm.com/-/media/Files/downloads/gnu-a/10.3-2021.07/binrel/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf.tar.xz",
+    "sha256": "6f74b1ee370caeb716688d2e467e5b44727fdc0ed56023fe5c72c0620019ecef"
+  },
+  "arm-clang": {
+    "url": "https://github.com/ARM-software/LLVM-embedded-toolchain-for-Arm/releases/download/release-19.1.1/LLVM-ET-Arm-19.1.1-Linux-x86_64.tar.xz",
+    "sha256": "f659c625302f6d3fb50f040f748206f6fd6bb1fc7e398057dd2deaf1c1f5e8d1"
+  },
+  "arm-gcc": {
+    "url": "https://gitlab.arm.com/api/v4/projects/tooling%2Fgnu-toolchains-for-arm/packages/generic/gnu-toolchain/14.2.rel1/arm-gnu-toolchain-14.2.rel1-x86_64-arm-none-eabi.tar.xz",
+    "sha256": "62a63b981fe391a9cbad7ef51b17e49aeaa3e7b0d029b36ca1e9c3b2a9b78823"
+  },
+  "ft9xx-gcc": {
+    "url": "https://github.com/Bridgetek/ft32-toolchain-linux/releases/download/v2.7.6/ft9xxtoolchain_2.7.6_amd64.deb",
+    "sha256": "cedc2ed01a3eb068b2341f1c9348c07de7d408fe15fe0144e38dd575356e038b"
+  },
+  "arm-gcc-macos-latest": {
+    "url": "https://gitlab.arm.com/api/v4/projects/tooling%2Fgnu-toolchains-for-arm/packages/generic/gnu-toolchain/14.2.rel1/arm-gnu-toolchain-14.2.rel1-darwin-arm64-arm-none-eabi.tar.xz",
+    "sha256": "c7c78ffab9bebfce91d99d3c24da6bf4b81c01e16cf551eb2ff9f25b9e0a3818"
+  },
+  "arm-gcc-windows-latest": {
+    "url": "https://gitlab.arm.com/api/v4/projects/tooling%2Fgnu-toolchains-for-arm/packages/generic/gnu-toolchain/14.2.rel1/arm-gnu-toolchain-14.2.rel1-mingw-w64-x86_64-arm-none-eabi.zip",
+    "sha256": "f074615953f76036e9a51b87f6577fdb4ed8e77d3322a6f68214e92e7859888f"
+  },
+  "msp430-gcc": {
+    "url": "https://software-dl.ti.com/msp430/msp430_public_sw/mcu/msp430/MSPGCC/9_2_0_0/export/msp430-gcc-9.2.0.50_linux64.tar.bz2",
+    "sha256": "a439316f586e5c8e4b18843e7f7648dfce01f3ea0ebd9ed6837bdcdf931c45c3"
+  },
+  "riscv-gcc": {
+    "url": "https://github.com/xpack-dev-tools/riscv-none-elf-gcc-xpack/releases/download/v13.2.0-2/xpack-riscv-none-elf-gcc-13.2.0-2-linux-x64.tar.gz",
+    "sha256": "52545afb900200fbf65fe05f7ce7090b8a42c64091f4f5d43cae6bb68ea2434a"
+  },
+  "rx-gcc": {
+    "url": "https://github.com/hathach/rx_device/releases/download/0.0.1/gcc-8.3.0.202411-GNURX-ELF.run",
+    "sha256": "498ba69b8f2dfe8b731e12709a1cf4c7d591ef2223fa1140e901fa9277f650f8"
+  },
+  "arm-iar": {
+    "url": "https://netstorage.iar.com/FileStore/STANDARD/001/003/723/cxarm-9.70.1.deb",
+    "sha256": "54c97d4379d1cc3bee3ee0e10423167aaf406aa037261e6f72894fd740753ece"
+  },
+  "iar-lmsc-tools": {
+    "url": "https://netstorage.iar.com/FileStore/STANDARD/001/003/926/iar-lmsc-tools_1.8_amd64.deb",
+    "sha256": "c54bdbaee9b4c73723ee4de4285bb6915bf05c9ec4643eaedf0b56cd8bf3e538"
+  }
 }
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 059ec1f..a77ac95 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -92,6 +92,12 @@
     entry: sh -c "python3 test/hil/test/test_ci_select.py && python3 test/hil/test/test_ci_metrics.py && cd test/hil/test && python3 -m unittest -q test_hil_util.BottomLayer"
     pass_filenames: false
     language: system
+  - id: setup-toolchain-test
+    name: setup-toolchain-test
+    files: ^(\.github/actions/setup_toolchain/|test/hil/test/test_setup_toolchain\.py$)
+    entry: python3 test/hil/test/test_setup_toolchain.py
+    pass_filenames: false
+    language: system
   # language: python, not system, for the next two hooks: their suites are
   # stdlib-only except where they parse YAML (skill front matter,
   # .pre-commit-config.yaml), so pre-commit's venv supplies PyYAML rather than
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
index 74c0cde..ca239f8 100644
--- a/test/hil/test/test_ci_select.py
+++ b/test/hil/test/test_ci_select.py
@@ -1291,6 +1291,7 @@
             'test/hil/test/test_hil_usbtest_verdict.py',
             'test/hil/test/test_hil_util.py',
             'test/hil/test/test_membrowse_cli.py',
+            'test/hil/test/test_setup_toolchain.py',
             'test/hil/test/usbtest_harness.py',
         ], 'test/hil/test/ gained or lost a file; it is carved out of rule 2, so confirm '
            'the rig still does not read anything in there before updating this list')
diff --git a/test/hil/test/test_setup_toolchain.py b/test/hil/test/test_setup_toolchain.py
new file mode 100644
index 0000000..2a1c51b
--- /dev/null
+++ b/test/hil/test/test_setup_toolchain.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""Tests for .github/actions/setup_toolchain: the toolchain manifest and fetch.sh, with curl stubbed."""
+import hashlib
+import json
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
+ACTION = os.path.join(REPO, '.github', 'actions', 'setup_toolchain')
+FETCH = os.path.join(ACTION, 'download', 'fetch.sh')
+PAYLOAD = 'toolchain bytes\n'
+DIGEST = hashlib.sha256(PAYLOAD.encode()).hexdigest()
+
+# writes CURL_PAYLOAD to the -o file and logs the call, or fails like curl -f on a 404 when CURL_FAIL is set
+CURL_STUB = '''#!/bin/sh
+echo "$@" >> "$CURL_LOG"
+[ -n "$CURL_FAIL" ] && exit 22
+while [ $# -gt 0 ]; do
+  [ "$1" = -o ] && { printf '%s' "$CURL_PAYLOAD" > "$2"; exit 0; }
+  shift
+done
+exit 2
+'''
+
+class Manifest(unittest.TestCase):
+    def test_every_entry_pins_an_https_url_and_sha256(self):
+        with open(os.path.join(ACTION, 'toolchain.json')) as f:
+            manifest = json.load(f)
+        for name, entry in manifest.items():
+            with self.subTest(name):
+                self.assertEqual(set(entry), {'url', 'sha256'})
+                self.assertTrue(entry['url'].startswith('https://'))
+                self.assertRegex(entry['sha256'], r'^[0-9a-f]{64}$')
+
+
+class Fetch(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.tmp)
+        # fetch.sh reads ../toolchain.json beside itself, so run a copy next to a fixture manifest
+        os.mkdir(os.path.join(self.tmp, 'download'))
+        self.fetch_sh = shutil.copy(FETCH, os.path.join(self.tmp, 'download', 'fetch.sh'))
+        self.bin = os.path.join(self.tmp, 'bin')
+        os.mkdir(self.bin)
+        self._write_exe('curl', CURL_STUB)
+        self.curl_log = os.path.join(self.tmp, 'curl.log')
+        self.out = os.path.join(self.tmp, 'toolchain.tar.xz')
+
+    def _write_exe(self, name, text):
+        path = os.path.join(self.bin, name)
+        with open(path, 'w') as f:
+            f.write(text)
+        os.chmod(path, 0o755)
+
+    def _manifest(self, **entries):
+        with open(os.path.join(self.tmp, 'toolchain.json'), 'w') as f:
+            json.dump(entries, f)
+
+    def fetch(self, name='tc', path=None, **env):
+        env = {'PATH': path or f'{self.bin}:{os.environ["PATH"]}', 'CURL_LOG': self.curl_log, 'CURL_PAYLOAD': PAYLOAD, **env}
+        return subprocess.run([self.fetch_sh, name, self.out], env=env, capture_output=True, text=True)
+
+    def curl_called(self):
+        return os.path.exists(self.curl_log)
+
+    def test_matching_digest_succeeds(self):
+        self._manifest(tc={'url': 'https://example.invalid/tc.tar.xz', 'sha256': DIGEST})
+        r = self.fetch()
+        self.assertEqual(r.returncode, 0, r.stderr)
+        with open(self.out) as f:
+            self.assertEqual(f.read(), PAYLOAD)
+        with open(self.curl_log) as f:
+            self.assertIn('https://example.invalid/tc.tar.xz', f.read())
+
+    def test_mismatched_digest_fails(self):
+        self._manifest(tc={'url': 'https://example.invalid/tc.tar.xz', 'sha256': '0' * 64})
+        self.assertNotEqual(self.fetch().returncode, 0)
+
+    def test_unknown_name_fails_before_download(self):
+        self._manifest(tc={'url': 'https://example.invalid/tc.tar.xz', 'sha256': DIGEST})
+        self.assertNotEqual(self.fetch('other').returncode, 0)
+        self.assertFalse(self.curl_called())
+
+    def test_missing_digest_fails_before_download(self):
+        self._manifest(tc={'url': 'https://example.invalid/tc.tar.xz'})
+        self.assertNotEqual(self.fetch().returncode, 0)
+        self.assertFalse(self.curl_called())
+
+    def test_download_failure_stops_before_verification(self):
+        self._manifest(tc={'url': 'https://example.invalid/tc.tar.xz', 'sha256': DIGEST})
+        self.assertEqual(self.fetch(CURL_FAIL='1').returncode, 22)
+
+    @unittest.skipUnless(shutil.which('shasum') and shutil.which('perl'), 'shasum not installed')
+    def test_falls_back_to_shasum_without_sha256sum(self):
+        # PATH holds only what fetch.sh and shasum need, so sha256sum is absent as on macOS
+        for tool in ('bash', 'dirname', 'jq', 'perl', 'shasum'):
+            os.symlink(shutil.which(tool), os.path.join(self.bin, tool))
+        self._manifest(good={'url': 'https://example.invalid/a', 'sha256': DIGEST},
+                       bad={'url': 'https://example.invalid/b', 'sha256': '0' * 64})
+        good = self.fetch('good', path=self.bin)
+        self.assertEqual(good.returncode, 0, good.stderr)
+        self.assertNotEqual(self.fetch('bad', path=self.bin).returncode, 0)
+
+    def test_native_windows_jq_line_endings(self):
+        # native jq.exe turns every LF it prints into CRLF; command substitution keeps the CR
+        self._write_exe('jq', f'#!/bin/bash\nset -o pipefail\n{shutil.which("jq")} "$@" | perl -pe "s/\\n/\\r\\n/"\n')
+        self._manifest(tc={'url': 'https://example.invalid/tc.tar.xz', 'sha256': DIGEST})
+        r = self.fetch()
+        self.assertEqual(r.returncode, 0, r.stderr)
+        with open(self.curl_log) as f:
+            self.assertIn('https://example.invalid/tc.tar.xz -o', f.read())
+
+    def test_curl_restricts_to_https_including_redirects(self):
+        # a behavioural check needs a TLS server; pin the flags that enforce it instead
+        with open(FETCH) as f:
+            script = f.read()
+        self.assertIn("--proto '=https'", script)
+        self.assertIn("--proto-redir '=https'", script)
+
+
+if __name__ == '__main__':
+    unittest.main()