diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f3db4f2..eba00042 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,7 @@ jobs: -e ENABLE_SANITIZER_UNDEFINED_BEHAVIOR="${ENABLE_SANITIZER_UNDEFINED_BEHAVIOR}" \ -e ENABLE_SANITIZER_ADDRESS="${ENABLE_SANITIZER_ADDRESS}" \ -e CI=true \ - $DEV_IMAGE \ + "$DEV_IMAGE" \ cmake --preset ci -DPE_USE_VENDORED_Z3=OFF -DLLVM_EXTERNAL_LIT=/usr/local/bin/lit -DLLVM_Z3_INSTALL_DIR=/usr/local - name: Build ${{ matrix.build-type }} with sanitizers set ${{ matrix.sanitizers }} @@ -139,6 +139,23 @@ jobs: run: | bash ./scripts/ghidra/build-headless-docker.sh + - name: Run cached patch matrix + if: matrix.build-type == 'Debug' && matrix.sanitizers == 'OFF' + run: | + docker run --rm \ + -v ${{ github.workspace }}:/workspace \ + -v /tmp/.gitconfig:/root/.gitconfig:ro \ + -w /workspace \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e CI=true \ + -e HOST_WORKSPACE=${{ github.workspace }} \ + $DEV_IMAGE \ + bash ./scripts/test-patch-matrix.sh \ + --build-type Debug \ + --build-root ci \ + --rebuild-firmware \ + --rebuild-fixtures + - name: Test ${{ matrix.build-type }} with sanitizers set ${{ matrix.sanitizers }} run: | docker run --rm \ @@ -149,4 +166,4 @@ jobs: -e CI=true \ -e HOST_WORKSPACE=${{ github.workspace }} \ $DEV_IMAGE \ - lit ./builds/ci/test -D BUILD_TYPE=${{ matrix.build-type }} -v -DCI_OUTPUT_FOLDER=/workspace/builds/ci/test/ghidra/Output \ No newline at end of file + lit ./builds/ci/test -D BUILD_TYPE=${{ matrix.build-type }} -v -DCI_OUTPUT_FOLDER=/workspace/builds/ci/test/ghidra/Output diff --git a/.gitignore b/.gitignore index f7ab3504..f5182269 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ .vscode/ build/ builds/ +firmwares/output/ +firmwares/repos/ cmake-build-*/ prefix/ .clangd @@ -16,4 +18,3 @@ _site/ .classpath .project .settings - diff --git a/docs/GettingStarted/build.md b/docs/GettingStarted/build.md index 87485324..4a8b3aac 100644 --- a/docs/GettingStarted/build.md +++ b/docs/GettingStarted/build.md @@ -2,6 +2,11 @@ # Building We mostly rely on a build container, but some dependencies are still needed outside that container: our fork of [LLVM20](https://github.com/trail-of-forks/clangir), a local copy of `lld`, and LLVM [LIT](https://llvm.org/docs/CommandGuide/lit.html). +From a fresh checkout, initialize vendored sources first: +```sh +git submodule update --init --recursive +``` + In order to set up those and build Patchestry, please follow the first-time instructions for your development environment of choice: - [macOS](#first-time-setup-macos) - [Linux](#first-time-setup-linux) @@ -27,10 +32,15 @@ See also: [Development](#development) ``` mkdir -p ~/.docker/cli-plugins ln -s $(which docker-buildx) ~/.docker/cli-plugins/docker-buildx - colima restart + colima start --vm-type vz docker buildx version + docker ps ``` + The validated Apple Silicon macOS path uses the `vz` backend. + The `linux/amd64` emulation path is materially slower and is not the routine + workflow described in this document. + 4. Log into Docker Hub (this may not be needed - it is not needed on Linux): ``` docker login -u @@ -52,16 +62,32 @@ See also: [Development](#development) The targets list of `"host;AArch64;ARM;X86"` is intentional (to always build host arch, AArch64, ARM, and x86), even if host arch is almost certainly either AArch64 or X86. +This must be the patched `trail-of-forks/clangir` toolchain, or an equivalent +install built from the same fork. A stock Homebrew `llvm` or `llvm@20` install +is not a supported substitute for host-native patchestry builds. +The `.devcontainer/README-HOST-BUILD.md` workflow builds a Linux arm64 toolchain +for container images; it is not a host-native macOS ClangIR install. + -6. Build with: +6. Configure and build with the patched ClangIR toolchain you just installed: ``` -CC=$(which clang) CXX=$(which clang++) cmake \ - --preset default \ - -DCMAKE_PREFIX_PATH=/lib/cmake/ \ +export LLVM_INSTALL_PREFIX= +export CC="${LLVM_INSTALL_PREFIX}/bin/clang" +export CXX="${LLVM_INSTALL_PREFIX}/bin/clang++" +export CMAKE_PREFIX_PATH="${LLVM_INSTALL_PREFIX}/lib/cmake/llvm;${LLVM_INSTALL_PREFIX}/lib/cmake/mlir;${LLVM_INSTALL_PREFIX}/lib/cmake/clang" + +cmake \ + --fresh --preset default \ -DLLVM_EXTERNAL_LIT=$(which lit) + +cmake --build --preset debug -j ``` -This setup provides a complete development environment for building and running the project on MacOS. The configuration uses Colima as a Docker backend, which provides better performance and resource management compared to Docker Desktop on MacOS. +This setup provides a host-native development environment when the patched +ClangIR fork is already installed. The configuration uses Colima as the Docker +backend for Docker-backed workflows on macOS. +This workflow expects `CC` and `CXX` to point at the patched ClangIR toolchain, +not AppleClang or a stock Homebrew LLVM install. # First Time Development Setup: Linux If you'd like to either follow step by step instructions or run a script to automatically follow them in a fresh Linux instance, here's a [Gist](https://gist.github.com/kaoudis/e734c6197dbed595586ab659844df737) that sets everything up from zero in a fresh VM for you and runs the Patchestry tests to confirm the setup works. This Gist should stay reasonably up to date since it's used to initialize ephemeral coding environments. It's been tested on Ubuntu 24.04. The only thing that should be different for other Ubuntus or for Debian is the `apt` package naming. @@ -77,8 +103,90 @@ Steps followed in the [Gist](https://gist.github.com/kaoudis/e734c6197dbed595586 # Development ## CMake Commands -- to build, see the command referenced in step 6 [above](#first-time-development-setup-macos) or the commands used for [Linux](#first-time-development-setup-linux). You'll use the `default` preset to configure and most likely the `debug` or `release` presets for the subsequent build command after configuration. -- to run tests, ensure the headless container is available first by running `scripts/ghidra/build-headless-docker.sh`, then you may `cmake --build builds/default/ -j$((`nproc`+1)) --preset debug --target test` (using the preset of your choice but selecting the `test` target) +- To build, configure with the `default` preset and build with `cmake --build --preset debug` or `cmake --build --preset release`. +- To run tests, first build the headless container with `scripts/ghidra/build-headless-docker.sh`, then run `ctest --preset debug --output-on-failure` or `lit ./builds/default/test -D BUILD_TYPE=Debug -v`. +- To run the cached patch/contract matrix from one command, use `scripts/test-patch-matrix.sh --build-type Debug`. +- To run the example firmware end-to-end flow and get a report, use `scripts/test-example-firmwares.sh --build-type Debug`. + +## Fresh checkout to validated build + +The validated Apple Silicon macOS path is the host-native patched ClangIR +workflow: + +```sh +git submodule update --init --recursive + +export LLVM_INSTALL_PREFIX= +export CC="${LLVM_INSTALL_PREFIX}/bin/clang" +export CXX="${LLVM_INSTALL_PREFIX}/bin/clang++" +export CMAKE_PREFIX_PATH="${LLVM_INSTALL_PREFIX}/lib/cmake/llvm;${LLVM_INSTALL_PREFIX}/lib/cmake/mlir;${LLVM_INSTALL_PREFIX}/lib/cmake/clang" + +cmake --fresh --preset default \ + -DLLVM_EXTERNAL_LIT=$(which lit) + +cmake --build --preset debug -j + +cmake -S lib/patchestry/intrinsics -B lib/patchestry/intrinsics/build_standalone \ + -DCMAKE_BUILD_TYPE=Release +cmake --build lib/patchestry/intrinsics/build_standalone -j + +bash ./scripts/ghidra/build-headless-docker.sh + +lit ./builds/default/test -D BUILD_TYPE=Debug -v +``` + +This validates: +1. native configure against the patched fork, +2. the Debug patchestry build, +3. the standalone intrinsics library, +4. the headless Ghidra Docker image on Apple Silicon, +5. the full lit tree. + +To validate the documented example firmware patching flow and generate a report: + +```sh +scripts/test-example-firmwares.sh --build-type Debug +``` + +This writes per-case artifacts plus: + +- `builds/example-firmware-e2e/summary.md` +- `builds/example-firmware-e2e/summary.tsv` + +To validate the broader patch/contract matrix from cached generated fixtures: + +```sh +scripts/test-patch-matrix.sh --build-type Debug +``` + +This reuses firmware artifacts in `firmwares/output/` and fixture caches in +`builds/test-fixtures/` when present. Use `--rebuild-firmware`, +`--rebuild-ghidra`, `--rebuild-fixtures`, or `--clean` to refresh caches +explicitly. + +This writes per-case artifacts plus: + +- `builds/patch-matrix/summary.md` +- `builds/patch-matrix/summary.tsv` + +Docker-backed workflows are still required for `build.sh` and Ghidra headless +tasks. On Apple Silicon, the routine workflow is the host-native path described +above. The default `linux/amd64` emulation path remains available, but with the +expected emulation overhead. +The validated Ghidra image build used Colima with the `vz` backend and built +Ghidra natives for `linux_arm_64`. + +CI uses the same high-level sequence on Linux: +1. Configure with `cmake --preset ci`. +2. Build with `cmake --build --preset ci --config `. +3. Build the standalone intrinsics library. +4. Build the headless Ghidra Docker image. +5. Run `scripts/test-patch-matrix.sh --build-type Debug --rebuild-firmware --rebuild-fixtures`. +6. Run `lit ./builds/ci/test`. + +The narrower example firmware runner remains available for focused inspection +and reporting. Use the opt-in CTest target by configuring with +`-DPE_ENABLE_EXAMPLE_FIRMWARE_E2E=ON` if you want CTest to invoke it. ## Ghidra diff --git a/docs/GettingStarted/firmware_examples.md b/docs/GettingStarted/firmware_examples.md index 556e3493..a7dff84d 100644 --- a/docs/GettingStarted/firmware_examples.md +++ b/docs/GettingStarted/firmware_examples.md @@ -1,5 +1,76 @@ # How To Run Patchestry on Firmware Examples +## Automated end-to-end runner + +The repository runner provides one command that: + +1. builds the example firmware artifacts, +2. decompiles representative example functions to JSON, +3. converts JSON to CIR, +4. applies the in-repo example patch specs, +5. lowers the patched CIR to LLVM IR, +6. writes a report and per-case logs/artifacts. + +```sh +scripts/test-example-firmwares.sh --build-type Debug +``` + +Artifacts and reports are written to: + +```sh +builds/example-firmware-e2e/ +``` + +The runner currently validates these repository-supported example cases: + +- `pulseox_measurement_update` +- `bloodlight_usb_send_message` +- `bloodview_device_process_entry` + +Generated reports: + +- `builds/example-firmware-e2e/summary.md` +- `builds/example-firmware-e2e/summary.tsv` + +The tested endpoint remains patched CIR and LLVM IR/bitcode, not a final +rewritten firmware binary. + +## Cached patch/contract matrix runner + +The matrix runner provides one command that: + +1. reuses or rebuilds the example firmware artifacts, +2. reuses or rebuilds cached decompile JSON and base CIR fixtures, +3. validates the repository-supported patch and contract spec matrix, +4. lowers each patched CIR to LLVM IR, +5. writes a summary report plus per-case logs and artifacts. + +```sh +scripts/test-patch-matrix.sh --build-type Debug +``` + +Artifacts and reports are written to: + +```sh +builds/patch-matrix/ +``` + +Fixture caches are written to: + +```sh +builds/test-fixtures/ +``` + +Firmware caches remain under: + +```sh +firmwares/output/ +``` + +By default the runner reuses any existing caches. Use `--rebuild-firmware`, +`--rebuild-ghidra`, `--rebuild-fixtures`, or `--clean` when you want to force +fresh inputs. + ## Build the Ghidra docker image First, make sure that the firwmare decompilation Ghidra docker image is set up correctly: @@ -31,18 +102,68 @@ For each firmware blob you want to decompile, use the decompile-headless script scripts/ghidra/decompile-headless.sh --input firmwares/output/bloodlight-firmware.elf --output ~/temp/patchestry/bloodlight-firmware.json ``` -This should produce the output json file, which can be used with tools like `pcode-lifter`. +This should produce the output JSON file, which can be consumed by `patchir-decomp`. -## Convert it to JSON to CIR +## Convert JSON to CIR -The JSON (which encompasses Ghidra high-pcode) can then be converted to ClangIR via `pcode-lifter` as follows: +The JSON (which encompasses Ghidra high-pcode) can then be converted to CIR via +`patchir-decomp` as follows: ```sh -builds/default/tools/pcode-lifter/Release/pcode-lifter --input ~/temp/patchestry/pulseox-firmware.json --emit-cir --output ~/temp/patchestry/pulseox-firmware_cir --print-tu +builds/default/tools/patchir-decomp/Debug/patchir-decomp \ + --input ~/temp/patchestry/pulseox-firmware.json \ + --emit-cir \ + --output ~/temp/patchestry/pulseox-firmware_cir \ + --print-tu ``` -The `--print-tu` argument is optional, it will emit C along with the ClangIR. The output looks like: +The `--print-tu` argument is optional; it emits C alongside the CIR. The output +looks like: ```sh ls -1 ~/temp/patchestry/pulseox-firmware_cir* /Users/artem/temp/patchestry/pulseox-firmware_cir.c /Users/artem/temp/patchestry/pulseox-firmware_cir.cir ``` + +## Optional patching and lowering flow + +Once you have CIR, the repository-supported patching flow is: + +```sh +# Validate a YAML patch specification +builds/default/tools/patchir-yaml-parser/Debug/patchir-yaml-parser patch.yaml --validate + +# Apply the patch spec to CIR +builds/default/tools/patchir-transform/Debug/patchir-transform \ + ~/temp/patchestry/pulseox-firmware_cir.cir \ + --spec patch.yaml \ + -o ~/temp/patchestry/pulseox-firmware_patched.cir + +# Lower patched CIR to LLVM IR +builds/default/tools/patchir-cir2llvm/Debug/patchir-cir2llvm \ + -S \ + ~/temp/patchestry/pulseox-firmware_patched.cir \ + -o ~/temp/patchestry/pulseox-firmware_patched.ll +``` + +This repository's native tested endpoint is patched CIR and LLVM IR/bitcode. +Producing a final rewritten firmware binary is downstream of patchestry and +typically handled by external tooling. + +## Opt-in automation via CTest + +If you want this flow exposed through CTest, reconfigure with: + +```sh +cmake --fresh --preset default \ + -DPE_ENABLE_EXAMPLE_FIRMWARE_E2E=ON \ + -DLLVM_EXTERNAL_LIT=$(which lit) +``` + +Then run: + +```sh +ctest --preset debug -R example-firmware-e2e-tests --output-on-failure +``` + +This target is opt-in because it builds external example firmware repositories +and requires Docker-backed Ghidra decompilation. diff --git a/firmwares/build.sh b/firmwares/build.sh old mode 100644 new mode 100755 index 508b2f35..0fedc5ca --- a/firmwares/build.sh +++ b/firmwares/build.sh @@ -1,45 +1,64 @@ #!/bin/bash -set -e +set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd "${script_dir}/.." && pwd) mkdir -p "${script_dir}/output" mkdir -p "${script_dir}/repos" +if [ -z "${BUILDX_CONFIG:-}" ]; then + export BUILDX_CONFIG="${repo_root}/builds/docker-buildx/firmwares" +fi +mkdir -p "${BUILDX_CONFIG}" + +translate_to_host_path() { + local path="$1" + if [ -n "${HOST_WORKSPACE:-}" ]; then + echo "${path/#\/workspace/$HOST_WORKSPACE}" + else + echo "$path" + fi +} + +host_script_dir="$(translate_to_host_path "${script_dir}")" +host_output_dir="$(translate_to_host_path "${script_dir}/output")" +host_repos_dir="$(translate_to_host_path "${script_dir}/repos")" + # Repository commit hashes PULSEOX_COMMIT="54ed8ca6bec36cc13db8f6594e3bd9941937922a" BLOODLIGHT_COMMIT="fcc0daef9119ab09914b0c523e7d9d93aad36ea4" # Clone/update repositories if needed if [ ! -d "${script_dir}/repos/pulseox-firmware" ]; then - git clone --depth 1 https://github.com/IRNAS/pulseox-firmware.git \ - "${script_dir}/repos/pulseox-firmware" - cd "${script_dir}/repos/pulseox-firmware" - git fetch --depth=1 origin ${PULSEOX_COMMIT} - git checkout ${PULSEOX_COMMIT} - git submodule update --init --recursive - patch -s -p1 < "${script_dir}/pulseox-firmware-patch.diff" + git clone --depth 1 https://github.com/IRNAS/pulseox-firmware.git \ + "${script_dir}/repos/pulseox-firmware" + cd "${script_dir}/repos/pulseox-firmware" + git fetch --depth=1 origin "${PULSEOX_COMMIT}" + git checkout "${PULSEOX_COMMIT}" + git submodule update --init --recursive + patch -s -p1 <"${script_dir}/pulseox-firmware-patch.diff" fi if [ ! -d "${script_dir}/repos/bloodlight-firmware" ]; then - git clone --depth 1 https://github.com/kumarak/bloodlight-firmware.git \ - "${script_dir}/repos/bloodlight-firmware" - cd "${script_dir}/repos/bloodlight-firmware" - git fetch --depth=1 origin ${BLOODLIGHT_COMMIT} - git checkout ${BLOODLIGHT_COMMIT} - git submodule update --init --recursive - patch -s -p1 < "${script_dir}/bloodlight-firmware-patch.diff" + git clone --depth 1 https://github.com/kumarak/bloodlight-firmware.git \ + "${script_dir}/repos/bloodlight-firmware" + cd "${script_dir}/repos/bloodlight-firmware" + git fetch --depth=1 origin "${BLOODLIGHT_COMMIT}" + git checkout "${BLOODLIGHT_COMMIT}" + git submodule update --init --recursive + patch -s -p1 <"${script_dir}/bloodlight-firmware-patch.diff" fi # Build using Docker -docker build -t firmware-builder ${script_dir} +docker build -t firmware-builder "${host_script_dir}" # Build pulseox firmware docker run --rm \ - -v "${script_dir}/repos/pulseox-firmware:/work/pulseox-firmware" \ - -v "${script_dir}/output:/output" \ - firmware-builder \ - -c "git config --global --add safe.directory /work/pulseox-firmware && \ + -v "${host_repos_dir}/pulseox-firmware:/work/pulseox-firmware" \ + -v "${host_output_dir}:/output" \ + firmware-builder \ + -c "git config --global --add safe.directory /work/pulseox-firmware && \ cd pulseox-firmware && \ cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/arm-none-eabi.cmake && \ cmake --build build -j\$(nproc) && \ @@ -47,10 +66,10 @@ docker run --rm \ # Build bloodlight firmware docker run --rm \ - -v "${script_dir}/repos/bloodlight-firmware:/work/bloodlight-firmware" \ - -v "${script_dir}/output:/output" \ - firmware-builder \ - -c "git config --global --add safe.directory /work/bloodlight-firmware && \ + -v "${host_repos_dir}/bloodlight-firmware:/work/bloodlight-firmware" \ + -v "${host_output_dir}:/output" \ + firmware-builder \ + -c "git config --global --add safe.directory /work/bloodlight-firmware && \ cd bloodlight-firmware && \ make -C firmware/libopencm3 && \ make -C firmware -j\$(nproc) && \ diff --git a/scripts/ghidra/build-headless-docker.sh b/scripts/ghidra/build-headless-docker.sh index 7f3feb8a..a04c5496 100755 --- a/scripts/ghidra/build-headless-docker.sh +++ b/scripts/ghidra/build-headless-docker.sh @@ -8,18 +8,31 @@ # This script preserves directory state for use in CI. SCRIPTS_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +REPO_ROOT="$(cd "${SCRIPTS_DIR}/../.." && pwd)" + +if [ -z "${BUILDX_CONFIG:-}" ]; then + export BUILDX_CONFIG="${REPO_ROOT}/builds/docker-buildx/ghidra" +fi +mkdir -p "${BUILDX_CONFIG}" + +translate_to_host_path() { + local path="$1" + if [ -n "${HOST_WORKSPACE:-}" ]; then + echo "${path/#\/workspace/$HOST_WORKSPACE}" + else + echo "$path" + fi +} + +HOST_SCRIPTS_DIR="$(translate_to_host_path "${SCRIPTS_DIR}")" echo "Using SCRIPTS_DIR: $SCRIPTS_DIR" +echo "Using BUILDX_CONFIG: $BUILDX_CONFIG" DOCKER_BUILDKIT=1 docker build \ - --no-cache \ - -t trailofbits/patchestry-decompilation:latest \ - -f "${SCRIPTS_DIR}/decompile-headless.dockerfile" \ - "${SCRIPTS_DIR}" - -if [ $? -eq 0 ]; then - echo "Docker image built successfully." -else - echo "Error: Docker build failed." - exit 1 -fi + --no-cache \ + -t trailofbits/patchestry-decompilation:latest \ + -f "${HOST_SCRIPTS_DIR}/decompile-headless.dockerfile" \ + "${HOST_SCRIPTS_DIR}" + +echo "Docker image built successfully." diff --git a/scripts/test-patch-matrix.sh b/scripts/test-patch-matrix.sh new file mode 100755 index 00000000..6a03370c --- /dev/null +++ b/scripts/test-patch-matrix.sh @@ -0,0 +1,577 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +BUILD_TYPE="Debug" +BUILD_ROOT="default" +FIXTURE_DIR="${REPO_ROOT}/builds/test-fixtures" +OUTPUT_DIR="${REPO_ROOT}/builds/patch-matrix" +REPORT_PREFIX="summary" +REBUILD_FIRMWARE=false +REBUILD_GHIDRA=false +REBUILD_FIXTURES=false +CLEAN=false + +PATCHIR_DECOMP="${REPO_ROOT}/builds/${BUILD_ROOT}/tools/patchir-decomp/${BUILD_TYPE}/patchir-decomp" +PATCHIR_TRANSFORM="${REPO_ROOT}/builds/${BUILD_ROOT}/tools/patchir-transform/${BUILD_TYPE}/patchir-transform" +PATCHIR_CIR2LLVM="${REPO_ROOT}/builds/${BUILD_ROOT}/tools/patchir-cir2llvm/${BUILD_TYPE}/patchir-cir2llvm" +PATCHIR_YAML_PARSER="${REPO_ROOT}/builds/${BUILD_ROOT}/tools/patchir-yaml-parser/${BUILD_TYPE}/patchir-yaml-parser" +DECOMPILER_HEADLESS="${REPO_ROOT}/scripts/ghidra/decompile-headless.sh" +GHIDRA_IMAGE="trailofbits/patchestry-decompilation:latest" + +show_help() { + cat < Ghidra JSON -> base CIR -> patch/contract matrix -> LLVM IR + +Options: + --build-type + Tool build configuration to use. Default: Debug + --build-root + Build tree root under builds/. Default: default + --fixture-dir + Directory for cached decompile/base-CIR fixtures. + Default: ${REPO_ROOT}/builds/test-fixtures + --output-dir + Directory for per-matrix outputs and reports. + Default: ${REPO_ROOT}/builds/patch-matrix + --report-prefix + Prefix for generated report files. Default: summary + --rebuild-firmware + Rebuild firmware artifacts in firmwares/output before running. + Implies --rebuild-fixtures. + --rebuild-ghidra + Rebuild the Ghidra headless Docker image before running. + --rebuild-fixtures + Regenerate cached JSON/CIR fixtures before running the matrix. + --clean + Remove cached fixtures and matrix outputs before running. + -h, --help + Show this help message and exit. +EOF +} + +require_file() { + local path="$1" + if [[ ! -e "${path}" ]]; then + echo "Missing required path: ${path}" >&2 + exit 1 + fi +} + +require_executable() { + local path="$1" + if [[ ! -x "${path}" ]]; then + echo "Missing required executable: ${path}" >&2 + exit 1 + fi +} + +prepare_tool_env() { + if [[ "$(uname -s)" == "Darwin" ]]; then + export SDKROOT + SDKROOT="$(xcrun --show-sdk-path)" + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --build-type) + BUILD_TYPE="$2" + shift 2 + ;; + --build-root) + BUILD_ROOT="$2" + shift 2 + ;; + --fixture-dir) + FIXTURE_DIR="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --report-prefix) + REPORT_PREFIX="$2" + shift 2 + ;; + --rebuild-firmware) + REBUILD_FIRMWARE=true + REBUILD_FIXTURES=true + shift + ;; + --rebuild-ghidra) + REBUILD_GHIDRA=true + shift + ;; + --rebuild-fixtures) + REBUILD_FIXTURES=true + shift + ;; + --clean) + CLEAN=true + shift + ;; + -h | --help) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + show_help >&2 + exit 1 + ;; + esac + done + + PATCHIR_DECOMP="${REPO_ROOT}/builds/${BUILD_ROOT}/tools/patchir-decomp/${BUILD_TYPE}/patchir-decomp" + PATCHIR_TRANSFORM="${REPO_ROOT}/builds/${BUILD_ROOT}/tools/patchir-transform/${BUILD_TYPE}/patchir-transform" + PATCHIR_CIR2LLVM="${REPO_ROOT}/builds/${BUILD_ROOT}/tools/patchir-cir2llvm/${BUILD_TYPE}/patchir-cir2llvm" + PATCHIR_YAML_PARSER="${REPO_ROOT}/builds/${BUILD_ROOT}/tools/patchir-yaml-parser/${BUILD_TYPE}/patchir-yaml-parser" +} + +setup_paths() { + if ${CLEAN}; then + rm -rf "${FIXTURE_DIR}" "${OUTPUT_DIR}" + fi + + mkdir -p "${FIXTURE_DIR}" "${OUTPUT_DIR}" + FIXTURE_DIR="$(cd "${FIXTURE_DIR}" && pwd)" + OUTPUT_DIR="$(cd "${OUTPUT_DIR}" && pwd)" + SUMMARY_MD="${OUTPUT_DIR}/${REPORT_PREFIX}.md" + SUMMARY_TSV="${OUTPUT_DIR}/${REPORT_PREFIX}.tsv" +} + +ensure_requirements() { + require_executable "${PATCHIR_DECOMP}" + require_executable "${PATCHIR_TRANSFORM}" + require_executable "${PATCHIR_CIR2LLVM}" + require_executable "${PATCHIR_YAML_PARSER}" + require_executable "${DECOMPILER_HEADLESS}" + require_file "${REPO_ROOT}/firmwares/build.sh" +} + +ensure_firmware_artifacts() { + local pulseox="${REPO_ROOT}/firmwares/output/pulseox-firmware.elf" + local bloodlight="${REPO_ROOT}/firmwares/output/bloodlight-firmware.elf" + local bloodview="${REPO_ROOT}/firmwares/output/bloodlight/bloodview" + + if ${REBUILD_FIRMWARE} || [[ ! -e "${pulseox}" ]] || [[ ! -e "${bloodlight}" ]] || [[ ! -e "${bloodview}" ]]; then + echo "Building example firmwares..." + (cd "${REPO_ROOT}" && bash ./firmwares/build.sh) + fi +} + +ensure_ghidra_image() { + if ${REBUILD_GHIDRA}; then + echo "Building Ghidra headless image..." + (cd "${REPO_ROOT}" && bash ./scripts/ghidra/build-headless-docker.sh) + return + fi + + if ! docker image inspect "${GHIDRA_IMAGE}" >/dev/null 2>&1; then + echo "Building Ghidra headless image..." + (cd "${REPO_ROOT}" && bash ./scripts/ghidra/build-headless-docker.sh) + fi +} + +fixture_binary_path() { + case "$1" in + pulseox_measurement_update) + echo "${REPO_ROOT}/firmwares/output/pulseox-firmware.elf" + ;; + bloodlight_usb_send_message) + echo "${REPO_ROOT}/firmwares/output/bloodlight-firmware.elf" + ;; + bloodview_device_process_entry) + echo "${REPO_ROOT}/firmwares/output/bloodlight/bloodview" + ;; + bloodlight_led_loop) + echo "${REPO_ROOT}/firmwares/output/bloodlight-firmware.elf" + ;; + *) + echo "Unknown fixture name: $1" >&2 + exit 1 + ;; + esac +} + +fixture_function_name() { + case "$1" in + pulseox_measurement_update) + echo "measurement_update" + ;; + bloodlight_usb_send_message) + echo "bl_usb__send_message" + ;; + bloodview_device_process_entry) + echo "bl_device__process_entry" + ;; + bloodlight_led_loop) + echo "bl_led_loop" + ;; + *) + echo "Unknown fixture name: $1" >&2 + exit 1 + ;; + esac +} + +prepare_fixture() { + local fixture_name="$1" + local case_dir="${FIXTURE_DIR}/${fixture_name}" + local binary + local function_name + + binary="$(fixture_binary_path "${fixture_name}")" + function_name="$(fixture_function_name "${fixture_name}")" + + require_file "${binary}" + mkdir -p "${case_dir}" + + local json="${case_dir}/${fixture_name}.json" + local cir_prefix="${case_dir}/${fixture_name}" + local cir="${cir_prefix}.cir" + local fixture_binary + fixture_binary="${case_dir}/$(basename "${binary}")" + + if ! ${REBUILD_FIXTURES} && [[ -s "${json}" && -s "${cir}" ]]; then + return + fi + + echo "Preparing fixture ${fixture_name}..." + if [[ -n "${HOST_WORKSPACE:-}" ]]; then + cp "${binary}" "${fixture_binary}" + "${DECOMPILER_HEADLESS}" \ + --input "${fixture_binary}" \ + --function "${function_name}" \ + --output "${json}" \ + --ci "${case_dir}" + else + "${DECOMPILER_HEADLESS}" --input "${binary}" --function "${function_name}" --output "${json}" + fi + "${PATCHIR_DECOMP}" -input "${json}" -emit-cir -output "${cir_prefix}" +} + +init_summary() { + cat >"${SUMMARY_MD}" <"${SUMMARY_TSV}" +} + +append_summary() { + local case_name="$1" + local status="$2" + local case_type="$3" + local fixture_name="$4" + local spec="$5" + local patched_cir="$6" + local llvm_ir="$7" + local log_file="$8" + + printf "| \`%s\` | \`%s\` | \`%s\` | \`%s\` | \`%s\` |\n" \ + "${case_name}" "${status}" "${case_type}" "${fixture_name}" "${spec}" >>"${SUMMARY_MD}" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "${case_name}" "${status}" "${case_type}" "${fixture_name}" "${spec}" \ + "${patched_cir}" "${llvm_ir}" "${log_file}" >>"${SUMMARY_TSV}" +} + +check_patterns() { + local file="$1" + shift + local pattern + for pattern in "$@"; do + if ! grep -Fq "${pattern}" "${file}"; then + echo "Missing expected pattern in ${file}: ${pattern}" >&2 + return 1 + fi + done +} + +run_matrix_case() { + local case_name="$1" + local fixture_name="$2" + local spec="$3" + local cir_patterns="$4" + local llvm_patterns="$5" + + local fixture_cir="${FIXTURE_DIR}/${fixture_name}/${fixture_name}.cir" + local case_dir="${OUTPUT_DIR}/${case_name}" + local patched_cir="${case_dir}/${case_name}.patched.cir" + local llvm_ir="${case_dir}/${case_name}.patched.ll" + local log_file="${case_dir}/run.log" + local status="PASS" + + mkdir -p "${case_dir}" + : >"${log_file}" + + { + echo "case=${case_name}" + echo "fixture=${fixture_name}" + echo "spec=${spec}" + echo "+ patchir-yaml-parser" + "${PATCHIR_YAML_PARSER}" "${spec}" --validate + echo "+ patchir-transform" + "${PATCHIR_TRANSFORM}" "${fixture_cir}" --spec "${spec}" -o "${patched_cir}" + echo "+ patchir-cir2llvm" + "${PATCHIR_CIR2LLVM}" -S "${patched_cir}" -o "${llvm_ir}" + } >>"${log_file}" 2>&1 || status="FAIL" + + if [[ "${status}" == "PASS" ]]; then + [[ -s "${patched_cir}" ]] || status="FAIL" + [[ -s "${llvm_ir}" ]] || status="FAIL" + fi + + if [[ "${status}" == "PASS" && -n "${cir_patterns}" ]]; then + local cir_pattern_array=() + IFS='|' read -r -a cir_pattern_array <<<"${cir_patterns}" + check_patterns "${patched_cir}" "${cir_pattern_array[@]}" >>"${log_file}" 2>&1 || status="FAIL" + fi + + if [[ "${status}" == "PASS" && -n "${llvm_patterns}" ]]; then + local llvm_pattern_array=() + IFS='|' read -r -a llvm_pattern_array <<<"${llvm_patterns}" + check_patterns "${llvm_ir}" "${llvm_pattern_array[@]}" >>"${log_file}" 2>&1 || status="FAIL" + fi + + append_summary \ + "${case_name}" \ + "${status}" \ + "positive" \ + "${fixture_name}" \ + "${spec}" \ + "${patched_cir}" \ + "${llvm_ir}" \ + "${log_file}" + + if [[ "${status}" != "PASS" ]]; then + FAIL_COUNT=$((FAIL_COUNT + 1)) + return 1 + fi + + PASS_COUNT=$((PASS_COUNT + 1)) +} + +run_negative_case() { + local case_name="$1" + local fixture_name="$2" + local spec="$3" + local command="$4" + local error_patterns="$5" + + local case_dir="${OUTPUT_DIR}/${case_name}" + local log_file="${case_dir}/run.log" + local status="PASS" + + mkdir -p "${case_dir}" + : >"${log_file}" + + { + echo "case=${case_name}" + echo "fixture=${fixture_name}" + echo "spec=${spec}" + echo "+ expecting failure" + } >>"${log_file}" + + if bash -lc "set -euo pipefail; ${command}" >>"${log_file}" 2>&1; then + echo "Command unexpectedly succeeded." >>"${log_file}" + status="FAIL" + fi + + if [[ "${status}" == "PASS" ]]; then + local error_pattern_array=() + IFS='|' read -r -a error_pattern_array <<<"${error_patterns}" + check_patterns "${log_file}" "${error_pattern_array[@]}" || status="FAIL" + fi + + append_summary \ + "${case_name}" \ + "${status}" \ + "negative" \ + "${fixture_name}" \ + "${spec}" \ + "-" \ + "-" \ + "${log_file}" + + if [[ "${status}" != "PASS" ]]; then + FAIL_COUNT=$((FAIL_COUNT + 1)) + return 1 + fi + + PASS_COUNT=$((PASS_COUNT + 1)) +} + +main() { + parse_args "$@" + prepare_tool_env + setup_paths + ensure_requirements + ensure_firmware_artifacts + ensure_ghidra_image + + prepare_fixture pulseox_measurement_update + prepare_fixture bloodlight_usb_send_message + prepare_fixture bloodview_device_process_entry + prepare_fixture bloodlight_led_loop + + init_summary + PASS_COUNT=0 + FAIL_COUNT=0 + CASE_FAILURE=0 + + run_matrix_case \ + "measurement_before_patch" \ + "pulseox_measurement_update" \ + "${REPO_ROOT}/test/patchir-transform/measurement_update_before_patch.yaml" \ + 'patch__before__spo2_lookup' \ + 'patchestry_operation' || CASE_FAILURE=1 + + run_matrix_case \ + "measurement_after_patch" \ + "pulseox_measurement_update" \ + "${REPO_ROOT}/test/patchir-transform/measurement_update_after_patch.yaml" \ + 'patch__after__spo2_lookup' \ + 'patchestry_operation' || CASE_FAILURE=1 + + run_matrix_case \ + "measurement_replace_patch" \ + "pulseox_measurement_update" \ + "${REPO_ROOT}/test/patchir-transform/measurement_update_replace_patch.yaml" \ + 'patch__replace__spo2_lookup' \ + 'patch__replace__spo2_lookup|patchestry_operation' || CASE_FAILURE=1 + + run_matrix_case \ + "measurement_before_operation" \ + "pulseox_measurement_update" \ + "${REPO_ROOT}/test/patchir-transform/measurement_update_before_operation.yaml" \ + 'patch__before__spo2_lookup' \ + 'patchestry_operation' || CASE_FAILURE=1 + + run_matrix_case \ + "measurement_operation_after" \ + "pulseox_measurement_update" \ + "${REPO_ROOT}/test/patchir-transform/operation_apply_after.yaml" \ + 'patch__after__spo2_lookup' \ + 'patchestry_operation' || CASE_FAILURE=1 + + run_matrix_case \ + "measurement_operation_replace" \ + "pulseox_measurement_update" \ + "${REPO_ROOT}/test/patchir-transform/operation_replace.yaml" \ + 'patch__replace__spo2_lookup' \ + 'patch__replace__spo2_lookup|patchestry_operation' || CASE_FAILURE=1 + + run_matrix_case \ + "usb_before_patch" \ + "bloodlight_usb_send_message" \ + "${REPO_ROOT}/test/patchir-transform/bl_usb__send_message_before_patch.yaml" \ + 'patch__before__usbd_ep_write_packet|contract__before__test_contract' \ + 'patch__before__usbd_ep_write_packet|contract__before__test_contract|patchestry_operation' || CASE_FAILURE=1 + + run_matrix_case \ + "usb_after_patch" \ + "bloodlight_usb_send_message" \ + "${REPO_ROOT}/test/patchir-transform/bl_usb__send_message_after_patch.yaml" \ + 'patch__after__usbd_ep_write_packet' \ + 'patch__after__usbd_ep_write_packet|patchestry_operation' || CASE_FAILURE=1 + + run_matrix_case \ + "usb_before_update_patch" \ + "bloodlight_usb_send_message" \ + "${REPO_ROOT}/test/patchir-transform/bl_usb__send_message_before_update_patch.yaml" \ + 'patch__before__usbd_cp_write_packet__update_state' \ + 'patch__before__usbd_cp_write_packet__update_state|patchestry_operation' || CASE_FAILURE=1 + + run_matrix_case \ + "usb_entrypoint_contract" \ + "bloodlight_usb_send_message" \ + "${REPO_ROOT}/test/patchir-transform/entrypoint_contract.yaml" \ + 'contract__entrypoint__message_entry_check|contract.static' \ + 'contract__entrypoint__message_entry_check|static_contract|msg_nonnull' || CASE_FAILURE=1 + + run_matrix_case \ + "bloodview_device_process_entry" \ + "bloodview_device_process_entry" \ + "${REPO_ROOT}/test/patchir-transform/device_process_entry.yaml" \ + 'patch__replace__sprintf|contract__sprintf|contract.static' \ + 'patch__replace__sprintf|contract__sprintf|static_contract' || CASE_FAILURE=1 + + run_matrix_case \ + "bloodview_all_predicates" \ + "bloodview_device_process_entry" \ + "${REPO_ROOT}/test/patchir-transform/all_predicates.yaml" \ + 'patch__replace__sprintf|contract.static|return_value_nonnegative' \ + 'patch__replace__sprintf|static_contract|dest_nonnull|return_value_nonnegative' || CASE_FAILURE=1 + + run_matrix_case \ + "led_loop_before_cmp_operation" \ + "bloodlight_led_loop" \ + "${REPO_ROOT}/test/patchir-transform/bl_led_loop_before_cmp_operation.yaml" \ + 'patch__before__cmp__bl_spi_mode' \ + 'patch__before__cmp__bl_spi_mode|patchestry_operation' || CASE_FAILURE=1 + + run_negative_case \ + "negative_missing_spec_file" \ + "pulseox_measurement_update" \ + "${FIXTURE_DIR}/does-not-exist.yaml" \ + "\"${PATCHIR_TRANSFORM}\" \"${FIXTURE_DIR}/pulseox_measurement_update/pulseox_measurement_update.cir\" --spec \"${FIXTURE_DIR}/does-not-exist.yaml\" -o \"${OUTPUT_DIR}/negative_missing_spec_file/unused.cir\"" \ + 'File does not exist|Failed to load file|failed to load config' || CASE_FAILURE=1 + + run_negative_case \ + "negative_malformed_schema" \ + "-" \ + "${REPO_ROOT}/test/patchir-transform/test_patch.yaml" \ + "\"${PATCHIR_YAML_PARSER}\" \"${REPO_ROOT}/test/patchir-transform/test_patch.yaml\" --validate" \ + 'error|missing required key|must include' || CASE_FAILURE=1 + + run_negative_case \ + "negative_bad_patch_reference" \ + "pulseox_measurement_update" \ + "${REPO_ROOT}/test/patchir-transform/bad_patch_ref.yaml" \ + "\"${PATCHIR_TRANSFORM}\" \"${FIXTURE_DIR}/pulseox_measurement_update/pulseox_measurement_update.cir\" --spec \"${REPO_ROOT}/test/patchir-transform/bad_patch_ref.yaml\" -o \"${OUTPUT_DIR}/negative_bad_patch_reference/unused.cir\"" \ + 'Patch specification for ID|not found|Failed to run instrumentation passes' || CASE_FAILURE=1 + + run_negative_case \ + "negative_missing_library_file" \ + "pulseox_measurement_update" \ + "${REPO_ROOT}/test/patchir-transform/missing_library.yaml" \ + "\"${PATCHIR_TRANSFORM}\" \"${FIXTURE_DIR}/pulseox_measurement_update/pulseox_measurement_update.cir\" --spec \"${REPO_ROOT}/test/patchir-transform/missing_library.yaml\" -o \"${OUTPUT_DIR}/negative_missing_library_file/unused.cir\"" \ + 'Failed to load library|File does not exist|Failed to run instrumentation passes' || CASE_FAILURE=1 + + run_negative_case \ + "negative_unsupported_argument_source" \ + "-" \ + "${REPO_ROOT}/test/patchir-transform/unsupported_argument_source.yaml" \ + "\"${PATCHIR_YAML_PARSER}\" \"${REPO_ROOT}/test/patchir-transform/unsupported_argument_source.yaml\" --validate" \ + 'Unknown argument source type|unsupported' || CASE_FAILURE=1 + + cat >>"${SUMMARY_MD}" <&2 + exit 1 + fi + + echo "Patch matrix validation passed. See ${SUMMARY_MD}" +} + +main "$@" diff --git a/test/patchir-transform/missing_library.yaml b/test/patchir-transform/missing_library.yaml new file mode 100644 index 00000000..12217da3 --- /dev/null +++ b/test/patchir-transform/missing_library.yaml @@ -0,0 +1,33 @@ +# Test specification: references a library file that does not exist. +apiVersion: patchestry.io/v1 + +metadata: + name: "missing-library-test" + description: "Spec that should fail because the referenced library file is absent" + version: "1.0.0" + author: "Security Team" + created: "2026-03-13" + +libraries: + - "patches/does_not_exist.yaml" + +target: + binary: "test_binary.bin" + arch: "ARM:LE:32" + +meta_patches: + - name: "missing_library_meta_patch" + description: "Unused action; parser should fail before this matters" + patch_actions: + - id: "MISSING-LIB-001" + description: "Should never execute" + match: + - name: "spo2_lookup" + kind: "function" + action: + - mode: "apply_before" + patch_id: "measurement_update_before_patch" + arguments: + - name: "function_argument" + source: "variable" + symbol: "var6" diff --git a/test/patchir-transform/unsupported_argument_source.yaml b/test/patchir-transform/unsupported_argument_source.yaml new file mode 100644 index 00000000..d558c7b8 --- /dev/null +++ b/test/patchir-transform/unsupported_argument_source.yaml @@ -0,0 +1,33 @@ +# Test specification: unsupported argument source type. +apiVersion: patchestry.io/v1 + +metadata: + name: "unsupported-argument-source-test" + description: "Spec that should fail because argument source is unsupported" + version: "1.0.0" + author: "Security Team" + created: "2026-03-13" + +libraries: + - "patches/measurement_update_patch.yaml" + +target: + binary: "test_binary.bin" + arch: "ARM:LE:32" + +meta_patches: + - name: "unsupported_argument_source_meta_patch" + description: "Uses an invalid argument source" + patch_actions: + - id: "UNSUPPORTED-SOURCE-001" + description: "Should fail during parsing" + match: + - name: "spo2_lookup" + kind: "function" + action: + - mode: "apply_before" + patch_id: "measurement_update_before_patch" + arguments: + - name: "function_argument" + source: "mystery_source" + symbol: "var6"