From 9e80740378f414654d27f0a1ecbdea26f92c028a Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 12 Apr 2026 13:04:20 +0800 Subject: [PATCH] Validate macOS app bundle dependencies --- scripts/install-flutter-mac-dmg.sh | 3 + scripts/package-flutter-mac-app.sh | 7 + scripts/validate-macos-app-bundle.sh | 209 +++++++++++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 scripts/validate-macos-app-bundle.sh diff --git a/scripts/install-flutter-mac-dmg.sh b/scripts/install-flutter-mac-dmg.sh index 870dca7d..fab540ca 100755 --- a/scripts/install-flutter-mac-dmg.sh +++ b/scripts/install-flutter-mac-dmg.sh @@ -42,6 +42,7 @@ validate_app_bundle() { return 1 } + bash "$ROOT_DIR/scripts/validate-macos-app-bundle.sh" "$app_path" codesign --verify --deep --verbose=2 "$app_path" } @@ -72,6 +73,8 @@ if [[ ! -d "$SOURCE_APP" ]]; then exit 1 fi +validate_app_bundle "$SOURCE_APP" + if [[ -d "$TARGET_APP" ]]; then echo "Replacing existing app at $TARGET_APP" rm -rf "$TARGET_APP" diff --git a/scripts/package-flutter-mac-app.sh b/scripts/package-flutter-mac-app.sh index ada68925..33d2de9e 100755 --- a/scripts/package-flutter-mac-app.sh +++ b/scripts/package-flutter-mac-app.sh @@ -96,8 +96,14 @@ verify_bundle_signature() { codesign --verify --deep --verbose=2 "$app_path" } +validate_bundle_dependencies() { + local app_path="$1" + bash "$ROOT_DIR/scripts/validate-macos-app-bundle.sh" "$app_path" +} + echo "Validating export compliance metadata..." bash "$ROOT_DIR/scripts/check-apple-export-compliance.sh" "$BUILD_APP_PATH" +validate_bundle_dependencies "$BUILD_APP_PATH" rm -rf "$DIST_APP_PATH" "$DIST_DMG_PATH" ditto "$BUILD_APP_PATH" "$DIST_APP_PATH" @@ -111,6 +117,7 @@ else fi verify_bundle_signature "$DIST_APP_PATH" +validate_bundle_dependencies "$DIST_APP_PATH" echo "Packaging DMG..." DMG_VOLUME_NAME="$APP_NAME" "$ROOT_DIR/scripts/create-dmg.sh" "$DIST_APP_PATH" "$DIST_DMG_PATH" diff --git a/scripts/validate-macos-app-bundle.sh b/scripts/validate-macos-app-bundle.sh new file mode 100644 index 00000000..345d6665 --- /dev/null +++ b/scripts/validate-macos-app-bundle.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_PATH="${1:-}" + +if [[ -z "$APP_PATH" ]]; then + echo "Usage: $0 /path/to/App.app" >&2 + exit 1 +fi + +if [[ ! -d "$APP_PATH" ]]; then + echo "App bundle not found: $APP_PATH" >&2 + exit 1 +fi + +APP_PATH="$(cd "$APP_PATH" && pwd -P)" + +INFO_PLIST="$APP_PATH/Contents/Info.plist" +if [[ ! -f "$INFO_PLIST" ]]; then + echo "Info.plist not found: $INFO_PLIST" >&2 + exit 1 +fi + +APP_EXECUTABLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$INFO_PLIST" 2>/dev/null || true)" +if [[ -z "$APP_EXECUTABLE" ]]; then + echo "Unable to read CFBundleExecutable from $INFO_PLIST" >&2 + exit 1 +fi + +MAIN_EXECUTABLE="$APP_PATH/Contents/MacOS/$APP_EXECUTABLE" +if [[ ! -x "$MAIN_EXECUTABLE" ]]; then + echo "Main executable not found: $MAIN_EXECUTABLE" >&2 + exit 1 +fi + +resolve_special_path() { + local path="$1" + local executable_dir="$2" + local loader_dir="$3" + + path="${path//@executable_path/$executable_dir}" + path="${path//@loader_path/$loader_dir}" + printf '%s\n' "$path" +} + +normalize_existing_path() { + local path="$1" + local dir + dir="$(cd "$(dirname "$path")" && pwd -P)" + printf '%s/%s\n' "$dir" "$(basename "$path")" +} + +extract_rpaths() { + local binary_path="$1" + local executable_dir="$2" + local loader_dir="$3" + + otool -l "$binary_path" 2>/dev/null | awk ' + $1 == "cmd" && $2 == "LC_RPATH" { want = 1; next } + want && $1 == "path" { print $2; want = 0 } + ' | while IFS= read -r rpath; do + resolve_special_path "$rpath" "$executable_dir" "$loader_dir" + done +} + +is_macho_file() { + local path="$1" + otool -L "$path" >/dev/null 2>&1 +} + +resolve_dependency() { + local dependency="$1" + local binary_path="$2" + local app_path="$3" + local executable_dir="$4" + local loader_dir + loader_dir="$(cd "$(dirname "$binary_path")" && pwd)" + + case "$dependency" in + /System/Library/*|/usr/lib/*) + return 0 + ;; + @executable_path/*|@loader_path/*) + local resolved + resolved="$(resolve_special_path "$dependency" "$executable_dir" "$loader_dir")" + [[ -e "$resolved" ]] && { + normalize_existing_path "$resolved" + return 0 + } + return 1 + ;; + @rpath/*) + while IFS= read -r rpath; do + local candidate="${dependency/@rpath/$rpath}" + candidate="$(resolve_special_path "$candidate" "$executable_dir" "$loader_dir")" + if [[ -e "$candidate" ]]; then + normalize_existing_path "$candidate" + return 0 + fi + done < <( + { + extract_rpaths "$binary_path" "$executable_dir" "$loader_dir" + printf '%s\n' "$app_path/Contents/Frameworks" + } | awk '!seen[$0]++' + ) + return 1 + ;; + /*) + if [[ -e "$dependency" ]]; then + normalize_existing_path "$dependency" + return 0 + fi + return 1 + ;; + *) + return 1 + ;; + esac +} + +validate_binary() { + local binary_path="$1" + local app_path="$2" + local executable_dir="$3" + local failures=0 + declare -A seen_dependencies=() + + while IFS= read -r line; do + [[ -n "$line" ]] || continue + + local dependency + dependency="$(awk '{print $1}' <<< "$line")" + [[ "$dependency" == "$binary_path" ]] && continue + [[ -n "${seen_dependencies[$dependency]:-}" ]] && continue + seen_dependencies["$dependency"]=1 + local is_weak=0 + [[ "$line" == *" weak)"* ]] && is_weak=1 + + if [[ "$dependency" == "/System/Library/"* || "$dependency" == "/usr/lib/"* ]]; then + continue + fi + + if ! resolve_dependency "$dependency" "$binary_path" "$app_path" "$executable_dir" >/dev/null; then + if (( is_weak )); then + echo "Warning: unresolved weak dependency in $binary_path -> $dependency" >&2 + continue + fi + echo "Missing dependency in app bundle:" >&2 + echo " Binary: $binary_path" >&2 + echo " Dependency: $dependency" >&2 + ((failures++)) + continue + fi + + local resolved_path + resolved_path="$(resolve_dependency "$dependency" "$binary_path" "$app_path" "$executable_dir")" + case "$resolved_path" in + /System/Library/*|/usr/lib/*) + ;; + "$app_path"/*) + ;; + *) + if (( ! is_weak )); then + echo "Non-system dependency resolves outside the app bundle:" >&2 + echo " Binary: $binary_path" >&2 + echo " Dependency: $dependency" >&2 + echo " Resolved: $resolved_path" >&2 + ((failures++)) + fi + ;; + esac + done < <( + otool -L "$binary_path" 2>/dev/null | tail -n +2 | sed 's/^[[:space:]]*//' + ) + + return "$failures" +} + +echo "Validating macOS app bundle dynamic dependencies: $APP_PATH" + +EXECUTABLE_DIR="$(cd "$APP_PATH/Contents/MacOS" && pwd)" +declare -a macho_files=("$MAIN_EXECUTABLE") + +if [[ -d "$APP_PATH/Contents/Frameworks" ]]; then + while IFS= read -r -d '' candidate; do + if is_macho_file "$candidate"; then + macho_files+=("$candidate") + fi + done < <(find "$APP_PATH/Contents/Frameworks" -type f -print0) +fi + +declare -A seen_binaries=() +failures=0 + +for binary_path in "${macho_files[@]}"; do + [[ -n "${seen_binaries[$binary_path]:-}" ]] && continue + seen_binaries["$binary_path"]=1 + + if ! validate_binary "$binary_path" "$APP_PATH" "$EXECUTABLE_DIR"; then + failures=1 + fi +done + +if (( failures != 0 )); then + echo "App bundle dependency validation failed: $APP_PATH" >&2 + exit 1 +fi + +echo "App bundle dependency validation passed: $APP_PATH"