From 616010f8c887b9d5fdaad51cf65c51182c4d4517 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sun, 3 May 2026 18:13:59 +0000 Subject: [PATCH] Verify package structural correctness across macOS, Windows, and OpenWrt builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add post-build structural verification steps to the three platform packaging workflows so structural defects abort before checksum/upload. The .pkg/.zip/.ipk builds run on tag (or every push for OpenWrt) but their output layouts were unverified — silent drift could ship missing binaries, broken plists, or malformed package containers that would only surface at install time. Field operators rely on manual validation; CI now catches packaging regressions before release. macOS (.github/workflows/package-macos.yml): The pkg is a pkgbuild component flat package; pkgutil --expand yields the structure but actual files live inside Payload (cpio.gz). The verification step extracts that with gzip -dc | cpio -i, then asserts: - usr/local/bin/fips, fipsctl, fipstop all present - Library/LaunchDaemons/com.fips.daemon.plist present - plutil -lint on the plist returns zero Each check prints PASS/FAIL; verifier dumps the full extracted tree on failure for diagnosis. Codesigning verification deferred (pkgbuild invocation has no --sign — nothing to verify yet). Windows (.github/workflows/package-windows.yml): build-zip.ps1 produces a flat archive (no wrapper directory) via Compress-Archive -Path "$StagingDir\*". The verifier extracts and asserts each expected file at the extract root: fips.exe, fipsctl.exe, fipstop.exe (binaries) fips.yaml, hosts (config from packaging/common/) install-service.ps1, uninstall-service.ps1 (from packaging/windows/) README.txt (generated inline by build script) Each check prints PASS/FAIL; verifier dumps the full extracted listing on failure for diagnosis. LICENSE intentionally not asserted: the build script does not currently ship one, and asserting on it would false-fail every build. OpenWrt (.github/workflows/package-openwrt.yml): Add four post-build verification steps between Build .ipk and SHA-256 hashes. The ipk build runs every push but its contents, shell-script lint state, and sysctl drop-in syntax were unverified. Steps: 1. Install shellcheck (conditional — pre-installed on ubuntu-latest; apt-get install fallback only if missing). 2. Lint init/uci/hotplug/firewall scripts: run shellcheck --shell=sh --exclude=SC1008,SC2317 against the 5 shipped #!/bin/sh scripts (fips, fips-gateway, firewall.sh, 99-fips hotplug, 90-fips-setup uci-default). SC1008 silences the rc.common shebang form; SC2317 silences "unreachable" warnings for externally-invoked rc.common hooks. 3. Sysctl drop-in syntax: per-line regex check against both fips-gateway.conf and fips-bridge.conf. 4. Verify ipk structural integrity: extract via tar -xzf (OpenWrt's actual format despite the misleading "ar archive" comment in build-ipk.sh), assert all three top-level entries (control.tar.gz, data.tar.gz, debian-binary), assert 14 file paths in data.tar.gz (binaries, init scripts, configs, sysctl drop-ins, hotplug + uci-defaults + sysupgrade keep), and assert the 4 control-tarball entries plus debian-binary content "2.0". Full ipk install/runtime test deferred past v0.3.0 scope. Operator-side note: testing/ci-local.sh NOT modified for the OpenWrt verifier (operators don't typically have the cross-compile toolchain locally). --- .github/workflows/package-macos.yml | 76 +++++++++++ .github/workflows/package-openwrt.yml | 182 ++++++++++++++++++++++++++ .github/workflows/package-windows.yml | 48 +++++++ 3 files changed, 306 insertions(+) diff --git a/.github/workflows/package-macos.yml b/.github/workflows/package-macos.yml index c6f27b6..60ad1ef 100644 --- a/.github/workflows/package-macos.yml +++ b/.github/workflows/package-macos.yml @@ -109,6 +109,82 @@ jobs: echo "pkg=$PKG_FILE" >> "$GITHUB_OUTPUT" + - name: Verify .pkg structural correctness + shell: bash + run: | + set -euo pipefail + PKG="${{ steps.macos-assets.outputs.pkg }}" + EXPAND_DIR="$(mktemp -d)/expanded" + PAYLOAD_DIR="$(mktemp -d)/payload" + fail=0 + + echo "==> Verifying $PKG" + + # 1) Flat-package expansion + if pkgutil --expand "$PKG" "$EXPAND_DIR"; then + echo "PASS: pkgutil --expand" + else + echo "FAIL: pkgutil --expand" + fail=1 + fi + + # Extract the cpio.gz Payload so we can inspect installed file layout + PAYLOAD_FILE="$(find "$EXPAND_DIR" -name Payload -type f | head -n 1)" + if [[ -z "$PAYLOAD_FILE" ]]; then + echo "FAIL: no Payload file inside expanded pkg" + fail=1 + else + mkdir -p "$PAYLOAD_DIR" + (cd "$PAYLOAD_DIR" && gzip -dc "$PAYLOAD_FILE" | cpio -i --quiet) + echo "PASS: extracted Payload to $PAYLOAD_DIR" + fi + + # 2) Binary at canonical install path (./usr/local/bin/fips inside payload) + BIN_PATH="$PAYLOAD_DIR/usr/local/bin/fips" + if [[ -f "$BIN_PATH" ]]; then + echo "PASS: binary present at usr/local/bin/fips" + else + echo "FAIL: binary missing at usr/local/bin/fips" + echo " fallback search:" + find "$PAYLOAD_DIR" -name fips -type f -print || true + fail=1 + fi + for extra in fipsctl fipstop; do + if [[ -f "$PAYLOAD_DIR/usr/local/bin/$extra" ]]; then + echo "PASS: binary present at usr/local/bin/$extra" + else + echo "FAIL: binary missing at usr/local/bin/$extra" + fail=1 + fi + done + + # 3) LaunchDaemon plist at canonical location + PLIST_PATH="$PAYLOAD_DIR/Library/LaunchDaemons/com.fips.daemon.plist" + if [[ -f "$PLIST_PATH" ]]; then + echo "PASS: plist present at Library/LaunchDaemons/com.fips.daemon.plist" + else + echo "FAIL: plist missing at Library/LaunchDaemons/com.fips.daemon.plist" + echo " fallback search:" + find "$PAYLOAD_DIR" -name '*.plist' -print || true + fail=1 + fi + + # 4) plutil -lint on the plist + if [[ -f "$PLIST_PATH" ]]; then + if plutil -lint "$PLIST_PATH"; then + echo "PASS: plutil -lint" + else + echo "FAIL: plutil -lint" + fail=1 + fi + fi + + if [[ "$fail" -ne 0 ]]; then + echo "==> .pkg verification FAILED" + exit 1 + fi + echo "==> .pkg verification PASSED" + - name: SHA-256 hash run: | echo "==> macOS release asset:" diff --git a/.github/workflows/package-openwrt.yml b/.github/workflows/package-openwrt.yml index 15fccaf..0529938 100644 --- a/.github/workflows/package-openwrt.yml +++ b/.github/workflows/package-openwrt.yml @@ -200,6 +200,188 @@ jobs: LLVM_STRIP: llvm-strip run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }} + - name: Install shellcheck (if missing) + shell: bash + run: | + if ! command -v shellcheck >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck + fi + shellcheck --version + + - name: Lint shipped shell scripts + shell: bash + run: | + set -euo pipefail + FILES_DIR=packaging/openwrt-ipk/files + # Scripts shipped inside the .ipk. The init scripts use the OpenWrt + # `#!/bin/sh /etc/rc.common` shebang; tell shellcheck to treat them + # as POSIX sh and silence the unrecognized-shebang warning (SC1008). + # SC2317 (unreachable command) fires on rc.common's externally-invoked + # start_service/stop_service/reload_service hooks. + TARGETS=( + "$FILES_DIR/etc/init.d/fips" + "$FILES_DIR/etc/init.d/fips-gateway" + "$FILES_DIR/etc/fips/firewall.sh" + "$FILES_DIR/etc/hotplug.d/net/99-fips" + "$FILES_DIR/etc/uci-defaults/90-fips-setup" + ) + fail=0 + for f in "${TARGETS[@]}"; do + if [ ! -f "$f" ]; then + echo "FAIL: missing $f" + fail=1 + continue + fi + echo "==> shellcheck $f" + if shellcheck --shell=sh --exclude=SC1008,SC2317 "$f"; then + echo " PASS" + else + echo " FAIL" + fail=1 + fi + done + if [ "$fail" -ne 0 ]; then + echo "shellcheck FAILED" + exit 1 + fi + echo "shellcheck PASS (${#TARGETS[@]} scripts)" + + - name: Sysctl drop-in syntax check + shell: bash + run: | + set -euo pipefail + FILES_DIR=packaging/openwrt-ipk/files + TARGETS=( + "$FILES_DIR/etc/sysctl.d/fips-gateway.conf" + "$FILES_DIR/etc/sysctl.d/fips-bridge.conf" + ) + fail=0 + for conf in "${TARGETS[@]}"; do + if [ ! -f "$conf" ]; then + echo "FAIL: missing $conf" + fail=1 + continue + fi + echo "==> validating $conf" + lineno=0 + file_ok=1 + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + # Skip comments and blank lines. + case "$line" in + ''|\#*) continue ;; + esac + # Match: = where key is dotted lower-id and value is + # an integer (sysctl drop-ins shipped here are all numeric toggles). + if ! [[ "$line" =~ ^[a-z0-9_.-]+[[:space:]]*=[[:space:]]*-?[0-9]+[[:space:]]*$ ]]; then + echo " FAIL line $lineno: $line" + file_ok=0 + fi + done < "$conf" + if [ "$file_ok" -eq 1 ]; then + echo " PASS" + else + fail=1 + fi + done + if [ "$fail" -ne 0 ]; then + echo "sysctl drop-in syntax check FAILED" + exit 1 + fi + echo "sysctl drop-in syntax check PASS" + + - name: Verify ipk structural integrity + shell: bash + run: | + set -euo pipefail + IPK="dist/${{ env.PACKAGE_FILENAME }}" + if [ ! -f "$IPK" ]; then + echo "FAIL: produced ipk not found at $IPK" + exit 1 + fi + echo "==> file type:" + file "$IPK" + + # OpenWrt .ipk = tar.gz containing debian-binary + control.tar.gz + + # data.tar.gz (NOT an ar archive like Debian's .deb). + WORK=$(mktemp -d) + trap 'rm -rf "$WORK"' EXIT + + tar -xzf "$IPK" -C "$WORK" + echo "==> top-level entries:" + ls -la "$WORK" + + # Top-level structural assertions. + fail=0 + for entry in debian-binary control.tar.gz data.tar.gz; do + if [ ! -f "$WORK/$entry" ]; then + echo "FAIL: missing top-level $entry" + fail=1 + else + echo " PASS top-level: $entry" + fi + done + if [ "$fail" -ne 0 ]; then exit 1; fi + + # debian-binary content sanity. + dbin_content=$(cat "$WORK/debian-binary" | tr -d '[:space:]') + if [ "$dbin_content" != "2.0" ]; then + echo "FAIL: debian-binary content is '$dbin_content' (expected 2.0)" + exit 1 + fi + echo " PASS debian-binary content: 2.0" + + # Inspect data.tar.gz contents. + DATA_LIST="$WORK/data.list" + tar -tzf "$WORK/data.tar.gz" > "$DATA_LIST" + echo "==> data.tar.gz entry count: $(wc -l < "$DATA_LIST")" + + # Required filesystem entries inside data.tar.gz. Entries are + # produced with a leading "./" by build-ipk.sh. + REQUIRED=( + ./usr/bin/fips + ./usr/bin/fipsctl + ./usr/bin/fipstop + ./usr/bin/fips-gateway + ./etc/init.d/fips + ./etc/init.d/fips-gateway + ./etc/fips/fips.yaml + ./etc/fips/firewall.sh + ./etc/dnsmasq.d/fips.conf + ./etc/sysctl.d/fips-gateway.conf + ./etc/sysctl.d/fips-bridge.conf + ./etc/hotplug.d/net/99-fips + ./etc/uci-defaults/90-fips-setup + ./lib/upgrade/keep.d/fips + ) + for path in "${REQUIRED[@]}"; do + if grep -Fxq "$path" "$DATA_LIST"; then + echo " PASS data: $path" + else + echo " FAIL data: missing $path" + fail=1 + fi + done + + # Inspect control.tar.gz: must contain control file + maintainer scripts. + CTRL_LIST="$WORK/control.list" + tar -tzf "$WORK/control.tar.gz" > "$CTRL_LIST" + echo "==> control.tar.gz entry count: $(wc -l < "$CTRL_LIST")" + for path in ./control ./conffiles ./postinst ./prerm; do + if grep -Fxq "$path" "$CTRL_LIST"; then + echo " PASS control: $path" + else + echo " FAIL control: missing $path" + fail=1 + fi + done + + if [ "$fail" -ne 0 ]; then + echo "ipk structural verification FAILED" + exit 1 + fi + echo "ipk structural verification PASS" + - name: SHA-256 hashes run: | echo "==> Binaries:" diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml index 5cd742c..5b98792 100644 --- a/.github/workflows/package-windows.yml +++ b/.github/workflows/package-windows.yml @@ -86,6 +86,54 @@ jobs: -Version "${{ needs.determine-versioning.outputs.package_version }}" ` -NoBuild + - name: Verify ZIP structural correctness + shell: pwsh + run: | + $zip = Get-ChildItem deploy\fips-*-windows-*.zip | Select-Object -First 1 + if (-not $zip) { Write-Error "No ZIP artifact found in deploy\"; exit 1 } + Write-Host "Verifying: $($zip.Name)" + + $extractDir = "verify-extract" + if (Test-Path $extractDir) { Remove-Item -Recurse -Force $extractDir } + Expand-Archive -Path $zip.FullName -DestinationPath $extractDir + + # Expected top-level files (flat ZIP, no wrapper directory). + # Source of truth: packaging/windows/build-zip.ps1 + $expected = @( + "fips.exe", + "fipsctl.exe", + "fipstop.exe", + "fips.yaml", + "hosts", + "install-service.ps1", + "uninstall-service.ps1", + "README.txt" + ) + + $missing = @() + foreach ($f in $expected) { + $path = Join-Path $extractDir $f + if (Test-Path -LiteralPath $path -PathType Leaf) { + Write-Host "PASS: $f" + } else { + Write-Host "FAIL: $f" + $missing += $f + } + } + + Write-Host "" + Write-Host "ZIP contents:" + Get-ChildItem -Path $extractDir -Recurse | ForEach-Object { + Write-Host " $($_.FullName.Substring((Resolve-Path $extractDir).Path.Length + 1))" + } + + if ($missing.Count -gt 0) { + Write-Error "Missing expected files: $($missing -join ', ')" + exit 1 + } + Write-Host "" + Write-Host "All expected files present." + - name: SHA-256 hash shell: pwsh run: |