Validate macOS app bundle dependencies

This commit is contained in:
Haitao Pan 2026-04-12 13:04:20 +08:00
parent 64e14beb70
commit 9e80740378
3 changed files with 219 additions and 0 deletions

View File

@ -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"

View File

@ -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"

View File

@ -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"