diff --git a/.ci/check-cppcheck.sh b/.ci/check-cppcheck.sh new file mode 100755 index 0000000..35fbf20 --- /dev/null +++ b/.ci/check-cppcheck.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# Run cppcheck static analysis on elfuse host source files (src/ only). +# Tests use raw-syscall stubs and are excluded. +# +# CI mode: --max-configs=1 + --enable=warning for speed. Generated headers +# under build/ that the source #includes must exist before invocation: +# build/dispatch.h -- via scripts/gen-syscall-dispatch.py +# build/version.h -- one-line #define +# build/shim_blob.h -- empty stub (the byte array contents are opaque +# to cppcheck and produce no useful findings) +# +# Generating real dispatch.h instead of stubbing it keeps cppcheck honest +# about the syscall dispatch layer. Stubbing shim_blob.h is acceptable +# because the file is just a byte array with no callable surface. + +set -e -u -o pipefail + +mapfile -d '' SOURCES < <(git ls-files -z -- 'src/*.c' 'src/**/*.c') + +if [ ${#SOURCES[@]} -eq 0 ]; then + echo "No tracked C source files found." + exit 0 +fi + +BUILD_DIR=$(mktemp -d) +trap 'rm -rf "$BUILD_DIR"' EXIT + +python3 scripts/gen-syscall-dispatch.py --output "$BUILD_DIR/dispatch.h" +printf '#define ELFUSE_VERSION "ci"\n' > "$BUILD_DIR/version.h" +# shim_blob.h declares an opaque byte array and its length; cppcheck +# gains nothing from the real contents, so a minimal stub suffices. +cat > "$BUILD_DIR/shim_blob.h" << 'EOF' +static const unsigned char shim_bin[1] = {0}; +static const unsigned int shim_bin_len = 1; +EOF + +# 120s is generous -- this should finish well below that with --max-configs=1. +timeout 120 cppcheck \ + -I. -Isrc -I"$BUILD_DIR" \ + --platform=unix64 \ + --enable=warning \ + --max-configs=1 --error-exitcode=1 --inline-suppr \ + --suppress=checkersReport --suppress=unmatchedSuppression \ + --suppress=missingIncludeSystem --suppress=noValidConfiguration \ + --suppress=normalCheckLevelMaxBranches \ + --suppress=preprocessorErrorDirective \ + --suppress=missingInclude \ + -D_GNU_SOURCE -D__APPLE__ -D__aarch64__ \ + "${SOURCES[@]}" diff --git a/.ci/check-format.sh b/.ci/check-format.sh new file mode 100755 index 0000000..27379e2 --- /dev/null +++ b/.ci/check-format.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# Verify clang-format conformance for all tracked C/H files in src/ and +# tests/. The repository's .clang-format is calibrated against +# clang-format-22; older versions produce different output and are +# rejected to keep CI deterministic. + +set -u -o pipefail + +if [ -z "${CLANG_FORMAT:-}" ]; then + if command -v clang-format-22 > /dev/null 2>&1; then + CLANG_FORMAT="clang-format-22" + elif command -v clang-format > /dev/null 2>&1; then + # Allow the unversioned binary only if it reports v22.x. + if clang-format --version 2> /dev/null | grep -qE 'version 22\.'; then + CLANG_FORMAT="clang-format" + else + echo "Error: clang-format-22 is required (older versions differ in style)" >&2 + exit 1 + fi + else + echo "Error: clang-format-22 is required (older versions differ in style)" >&2 + exit 1 + fi +fi + +ret=0 +while IFS= read -r -d '' file; do + expected=$(mktemp) + "$CLANG_FORMAT" "$file" > "$expected" 2> /dev/null + if ! diff -u -p --label="$file" --label="expected coding style" "$file" "$expected"; then + ret=1 + fi + rm -f "$expected" +done < <(git ls-files -z -- 'src/*.c' 'src/*.h' 'src/**/*.c' 'src/**/*.h' \ + 'tests/*.c' 'tests/*.h') + +exit $ret diff --git a/.ci/check-newline.sh b/.ci/check-newline.sh new file mode 100755 index 0000000..f239d23 --- /dev/null +++ b/.ci/check-newline.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# Ensure all tracked C/H/S/sh files end with a newline. + +set -e -u -o pipefail + +ret=0 +while IFS= read -rd '' f; do + # `-b` prints just the encoding (e.g. "us-ascii", "binary", "utf-8") + # without the filename, so a path containing the word "binary" can't + # cause a non-binary file to be skipped. + if [ "$(file -b --mime-encoding -- "$f")" != "binary" ]; then + if [ -n "$(tail -c1 < "$f")" ]; then + echo "Warning: No newline at end of file $f" + ret=1 + fi + fi +done < <(git ls-files -z -- '*.c' '*.h' '*.S' '*.sh' '*.py' '*.mk' Makefile) + +exit $ret diff --git a/.ci/check-security.sh b/.ci/check-security.sh new file mode 100755 index 0000000..9b3522d --- /dev/null +++ b/.ci/check-security.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# Security checks for elfuse host source files (src/ only). +# Tests are excluded -- they exercise unsafe patterns deliberately. +# +# 1. Banned functions -- unsafe libc calls with safer alternatives. +# 2. Credential / secret patterns -- catch accidental key leaks. +# 3. Dangerous preprocessor -- detect disabled security features. + +set -u -o pipefail + +failed=0 + +# --- Patterns --- +banned='(^|[^[:alnum:]_])(gets|sprintf|vsprintf|strcpy|stpcpy|strcat|atoi|atol|atoll|atof|mktemp|tmpnam|tempnam)[[:space:]]*\(' +secrets='(password|secret|api_key|private_key|token)[[:space:]]*=[[:space:]]*"[^"]+' +dangerous_pp='#[[:space:]]*(undef|define)[[:space:]]+((_FORTIFY_SOURCE[[:space:]]+0)|(__SSP__))' +comment_only='^[[:space:]]*(//|/\*|\*|\*/)' + +# Only scan elfuse host source, not tests/ or assembly shim. +# +# Each match uses process substitution rather than a shell pipeline: +# under `pipefail`, an early `grep -q` exit closes its stdin, the +# upstream filter receives SIGPIPE, and the pipeline returns non-zero +# even when the pattern matched -- silently dropping real findings. +# Process substitution puts the filter in a separate process whose exit +# status doesn't feed back into the matcher. +while IFS= read -r -d '' f; do + if grep -qE "$banned" < <(grep -vE "$comment_only" -- "$f"); then + echo "Banned function in $f:" + grep -nE "$banned" -- "$f" | grep -vE "$comment_only" || true + failed=1 + fi + if grep -iqE "$secrets" < <(grep -vE "$comment_only" -- "$f"); then + echo "Possible hardcoded secret in $f:" + grep -inE "$secrets" -- "$f" | grep -vE "$comment_only" || true + failed=1 + fi + if grep -qE "$dangerous_pp" < <(grep -vE "$comment_only" -- "$f"); then + echo "Dangerous preprocessor directive in $f:" + grep -nE "$dangerous_pp" -- "$f" | grep -vE "$comment_only" || true + failed=1 + fi +done < <(git ls-files -z -- 'src/*.c' 'src/*.h' 'src/**/*.c' 'src/**/*.h') + +if [ $failed -eq 0 ]; then + echo "Security checks passed." +fi + +exit $failed diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..54f286f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,261 @@ +# Build elfuse and run the lint + analysis suites. +# +# Four jobs run in parallel: +# lint : format/newline/security/cppcheck/dispatch on Linux +# build-macos : compile + entitlement check on macOS Apple Silicon +# tidy-macos : clang-tidy via `make lint` +# scan-macos : LLVM scan-build via `make analyze` +# +# Runtime tests (test-hello, make check, test-multi-vcpu) require +# Hypervisor.framework, which GitHub-hosted macOS runners do not expose +# (the runner OS itself runs under a virtualization layer that withholds +# HVF and returns HV_UNSUPPORTED from hv_vm_create). Those tests must run +# on a self-hosted Apple Silicon runner; the hosted job stops at build. +# +# Within the lint job, all sub-checks run even if an earlier one fails so +# the report shows every problem at once instead of stopping at the first. +name: CI + +on: + push: + branches: [main] + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + pull_request: + branches: [main] + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + workflow_dispatch: + +# Cancel in-progress runs for the same PR; keep main runs going. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + # Lint: formatting + static analysis on a fast Linux runner. + lint: + name: Lint (Linux) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + # Single source of truth for the apt package list. Used by both the + # cache key (so unrelated workflow edits don't bust the cache) and + # the install step. + LINT_PKGS: clang-format-22 cppcheck shellcheck + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Cache apt packages + uses: actions/cache@v5 + with: + path: ~/apt-cache + key: apt-${{ runner.os }}-${{ env.LINT_PKGS }} + + - name: Add LLVM apt repo (clang-format-22) + # Place the key in /etc/apt/keyrings and bind it via signed-by so + # it grants trust only to the LLVM repository, not system-wide. + run: | + set -euo pipefail + sudo install -d -m 0755 /etc/apt/keyrings + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key \ + | sudo tee /etc/apt/keyrings/llvm.asc > /dev/null + echo "deb [signed-by=/etc/apt/keyrings/llvm.asc] http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" \ + | sudo tee /etc/apt/sources.list.d/llvm.list + + - name: Install tools + run: | + set -euo pipefail + mkdir -p ~/apt-cache + sudo apt-get update + # shellcheck disable=SC2086 -- LINT_PKGS is a space-separated list. + sudo apt-get install -y -o Dir::Cache::Archives="$HOME/apt-cache" \ + $LINT_PKGS + + - name: Trailing newline + if: ${{ !cancelled() }} + run: .ci/check-newline.sh + + - name: clang-format + if: ${{ !cancelled() }} + run: .ci/check-format.sh + + - name: Banned APIs / secrets / unsafe pp directives + if: ${{ !cancelled() }} + run: .ci/check-security.sh + + - name: shellcheck + # Scoped to .ci/ -- tests/ has pre-existing warnings that the + # repository's own check-format target already surfaces. + if: ${{ !cancelled() }} + run: | + set -euo pipefail + mapfile -d '' files < <(git ls-files -z -- '.ci/*.sh') + shellcheck --severity=warning "${files[@]}" + + - name: cppcheck + if: ${{ !cancelled() }} + run: .ci/check-cppcheck.sh + + - name: Syscall dispatch table consistency + # The generator validates dispatch.tbl <-> syscall.c on every run; + # writing to a throwaway path is enough to exercise validate_wrappers(). + if: ${{ !cancelled() }} + run: python3 scripts/gen-syscall-dispatch.py --output "$RUNNER_TEMP/dispatch.h" + + # Build verification on macOS Apple Silicon (no HVF runtime tests). + # Hosted runners don't expose Hypervisor.framework, so this job stops at + # `make elfuse` + entitlement check. + build-macos: + name: Build (macOS Apple Silicon) + runs-on: macos-15 + timeout-minutes: 15 + env: + GNU_OBJCOPY: /opt/homebrew/opt/binutils/bin/objcopy + HOMEBREW_NO_INSTALL_CLEANUP: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + BREW_PKGS: binutils + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Cache Homebrew downloads + # No restore-keys: a partial match would mask upstream regressions. + uses: actions/cache@v5 + with: + path: ~/Library/Caches/Homebrew/downloads + key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} + + - name: Confirm host is arm64 + run: | + set -euo pipefail + uname -mrs + test "$(uname -m)" = "arm64" + + - name: Install GNU objcopy + # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. + run: | + set -euo pipefail + brew install --quiet $BREW_PKGS + "$GNU_OBJCOPY" --version | head -1 + + - name: Build elfuse + run: | + set -euo pipefail + clang --version | head -1 + make elfuse + + - name: Verify HVF entitlement is embedded + run: | + set -euo pipefail + codesign -d --entitlements - build/elfuse 2>&1 \ + | grep -q 'com\.apple\.security\.hypervisor' + + - name: Upload elfuse binary + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: elfuse-${{ runner.os }}-${{ runner.arch }} + path: build/elfuse + retention-days: 7 + if-no-files-found: warn + + # clang-tidy via `make lint`. Runs in parallel with build/scan jobs. + # Advisory: .clang-tidy sets WarningsAsErrors='', so findings are logged + # for review but do not gate the job. + tidy-macos: + name: clang-tidy (macOS Apple Silicon) + runs-on: macos-15 + timeout-minutes: 20 + env: + GNU_OBJCOPY: /opt/homebrew/opt/binutils/bin/objcopy + HOMEBREW_NO_INSTALL_CLEANUP: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + # binutils is needed because make lint depends on the shim_blob.h + # generated by the assembly + objcopy pipeline. + BREW_PKGS: binutils llvm + CLANG_TIDY: /opt/homebrew/opt/llvm/bin/clang-tidy + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Cache Homebrew downloads + uses: actions/cache@v5 + with: + path: ~/Library/Caches/Homebrew/downloads + key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} + + - name: Install Homebrew packages + # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. + run: | + set -euo pipefail + brew install --quiet $BREW_PKGS + "$CLANG_TIDY" --version | head -1 + + - name: Generate build/dispatch.h, shim_blob.h, version.h + # `make lint` depends on these generated headers; building the + # full elfuse binary is unnecessary, so just satisfy the deps. + run: make build/shim_blob.h build/version.h build/dispatch.h + + - name: clang-tidy (make lint) + run: make lint + + # LLVM scan-build via `make analyze`. Runs in parallel with build/tidy. + # Advisory: scan-build's Make target does not pass --status-bugs, so + # findings appear in logs and in the uploaded HTML report but do not + # gate the job. + scan-macos: + name: scan-build (macOS Apple Silicon) + runs-on: macos-15 + timeout-minutes: 25 + env: + GNU_OBJCOPY: /opt/homebrew/opt/binutils/bin/objcopy + HOMEBREW_NO_INSTALL_CLEANUP: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + BREW_PKGS: binutils llvm + LLVM_BIN: /opt/homebrew/opt/llvm/bin + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Cache Homebrew downloads + uses: actions/cache@v5 + with: + path: ~/Library/Caches/Homebrew/downloads + key: brew-${{ runner.os }}-${{ runner.arch }}-${{ env.BREW_PKGS }} + + - name: Install Homebrew packages + # shellcheck disable=SC2086 -- BREW_PKGS is a space-separated list. + # scan-build has no --version; piping --help into `head -1` makes + # perl take SIGPIPE on the closed stdout and exit non-zero, which + # under pipefail fails the step. Just confirm the binary exists. + run: | + set -euo pipefail + brew install --quiet $BREW_PKGS + test -x "$LLVM_BIN/scan-build" + "$LLVM_BIN/clang" --version | head -1 + + - name: scan-build (make analyze) + run: | + set -euo pipefail + export PATH="$LLVM_BIN:$PATH" + mkdir -p build/scan-build + scan-build -o build/scan-build --use-cc="$(command -v clang)" \ + make -B elfuse + + - name: Upload scan-build report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: scan-build-${{ runner.os }}-${{ runner.arch }} + path: build/scan-build + retention-days: 7 + if-no-files-found: ignore diff --git a/src/runtime/proctitle.c b/src/runtime/proctitle.c index b77fb4a..10cbacf 100644 --- a/src/runtime/proctitle.c +++ b/src/runtime/proctitle.c @@ -71,8 +71,6 @@ void runtime_set_process_title(int argc, char **argv, const char *elf_path) extern char **environ; char **new_environ = NULL; size_t avail; - const char *slash = strrchr(elf_path, '/'); - const char *bin = slash ? slash + 1 : elf_path; const char *arch = "aarch64"; char title[256]; char thread_name[64]; @@ -81,6 +79,9 @@ void runtime_set_process_title(int argc, char **argv, const char *elf_path) if (argc <= 0 || !argv || !argv[0] || !elf_path || !environ) return; + const char *slash = strrchr(elf_path, '/'); + const char *bin = slash ? slash + 1 : elf_path; + snprintf(title, sizeof(title), "%s (%s-linux)", bin, arch); title_len = strlen(title); sysctlbyname("kern.procname", NULL, NULL, title, title_len); diff --git a/src/syscall/io.c b/src/syscall/io.c index cc344ab..bfb14b1 100644 --- a/src/syscall/io.c +++ b/src/syscall/io.c @@ -89,6 +89,8 @@ static void termios_copy_cc_to_linux(uint8_t linux_cc[19], const cc_t mac_cc[]) { for (int i = 0; i < 19; i++) { int mac_idx = linux_mac_cc[i]; + // cppcheck-suppress negativeIndex + // RANGE_CHECK guards mac_idx >= 0 before the array access. linux_cc[i] = RANGE_CHECK(mac_idx, 0, NCCS) ? mac_cc[mac_idx] : 0; } }