This is an automated email from the ASF dual-hosted git repository. jimjag pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/openoffice-devtools.git
commit c7b4dec5c72fae5fbf3edee26918643dbd31615c Author: Jim Jagielski <[email protected]> AuthorDate: Thu Sep 24 16:36:48 2026 -0400 macosx-remote-sign.sh: bundle trunk's signing helpers, sign 4.1.x dmgs --- release-scripts/macosx-check-load-commands.sh | 56 ++++ release-scripts/macosx-codesign-entitlements.plist | 21 ++ release-scripts/macosx-codesign.sh | 307 +++++++++++++++++++++ release-scripts/macosx-remote-sign.sh | 117 ++++++-- release-scripts/tests/conftest.py | 88 +++++- release-scripts/tests/test_macosx_remote_sign.py | 129 ++++++++- 6 files changed, 699 insertions(+), 19 deletions(-) diff --git a/release-scripts/macosx-check-load-commands.sh b/release-scripts/macosx-check-load-commands.sh new file mode 100755 index 0000000..5f1f25e --- /dev/null +++ b/release-scripts/macosx-check-load-commands.sh @@ -0,0 +1,56 @@ +#!/bin/bash +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** +# +# Fail when a Mach-O under the given paths still loads from or searches a +# build-time location: the CPython staging prefix or an unresolved @_______ +# placeholder that macosx-change-install-names.pl should have rewritten. +# +# ./solenv/bin/macosx-check-load-commands.sh <bundle-or-file> ... + +set -euo pipefail + +[ $# -gt 0 ] || { echo "usage: $(basename "$0") <bundle-or-file> ..." >&2; exit 2; } + +machos=() +while IFS= read -r -d '' f && IFS= read -r type; do + case "$type" in ": Mach-O"*) machos+=("$f") ;; esac +done < <(find "$@" -type f -print0 | xargs -0 file --no-pad --print0 -- 2>/dev/null) +# Every caller passes a real app bundle or binary that is expected to contain +# Mach-O content; zero matches means a wrong path or missing build output, not +# "nothing to check" - fail loudly instead of silently passing. +if [ ${#machos[@]} -eq 0 ]; then + echo "no Mach-O files found under: $*" >&2 + exit 1 +fi + +# A dylib's own ID (LC_ID_DYLIB) is never used to locate it at runtime. +bad=$(printf '%s\0' "${machos[@]}" | xargs -0 otool -l | awk ' + /^[^[:space:]].*:$/ { file = $0; next } + $1 == "cmd" { cmd = $2; next } + cmd != "LC_ID_DYLIB" && ($1 == "name" || $1 == "path") && /python-inst|@_______/ { + print file " " cmd " " $2 + }') +if [ -n "$bad" ]; then + echo "unrelocated Mach-O load commands:" >&2 + echo "$bad" >&2 + exit 1 +fi diff --git a/release-scripts/macosx-codesign-entitlements.plist b/release-scripts/macosx-codesign-entitlements.plist new file mode 100644 index 0000000..ca5913f --- /dev/null +++ b/release-scripts/macosx-codesign-entitlements.plist @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <!-- The bundled JVM/Java bridge and Basic runtime generate and run code at runtime. --> + <key>com.apple.security.cs.allow-jit</key> + <true/> + <key>com.apple.security.cs.allow-unsigned-executable-memory</key> + <true/> + <!-- soffice dlopens an external JDK's libjvm.dylib, user-installed UNO extensions + and Python modules; none of those carry our Team ID. --> + <key>com.apple.security.cs.disable-library-validation</key> + <true/> + <!-- sofficerc / the launcher set DYLD_* to find the bundled URE libraries. --> + <key>com.apple.security.cs.allow-dyld-environment-variables</key> + <true/> + <!-- Mail merge, "Send Document as E-Mail" and the installer script drive other apps. --> + <key>com.apple.security.automation.apple-events</key> + <true/> +</dict> +</plist> diff --git a/release-scripts/macosx-codesign.sh b/release-scripts/macosx-codesign.sh new file mode 100755 index 0000000..40c8970 --- /dev/null +++ b/release-scripts/macosx-codesign.sh @@ -0,0 +1,307 @@ +#!/bin/bash +# +# Code-sign a built Apache OpenOffice .app (or .dmg) on macOS. +# +# ./solenv/bin/macosx-codesign.sh [options] <OpenOffice.app|installer.dmg> ... +# +# Options: +# -i, --identity ID codesign identity; "-" = ad-hoc (default, or +# $MACOSX_CODESIGNING_IDENTITY when set) +# -e, --entitlements entitlements plist (default: macosx-codesign-entitlements.plist) +# -k, --keychain PATH keychain holding the identity (default: the search list) +# --hardened force hardened runtime even for an ad-hoc signature +# (implied by a real identity) +# --notarize PROFILE +# after signing, submit to the Apple notary service with +# the "xcrun notarytool store-credentials" keychain +# profile PROFILE and staple the ticket (.dmg, or a .app +# zipped for submission); needs a real identity +# --release fail if the Gatekeeper (spctl) assessment rejects the +# result, instead of only reporting it; ignored for an +# ad-hoc identity, which spctl always rejects regardless +# of notarization +# --verify only report the current signing state, change nothing +# +# The linker already ad-hoc-signs each Mach-O it produces, which is why the +# binaries load at all on arm64. What it does not do is seal the *bundles*: +# without a _CodeSignature/CodeResources the .app has no identity, Gatekeeper +# rejects it once it carries a quarantine flag, and it can never be notarized. +# +# This signs inside-out -- every Mach-O, then the nested bundles, then the app. +# It relies on the installation being laid out so that Contents/MacOS holds +# only the soffice launcher (see scp2/source/ooo/common_brand.scp); codesign +# refuses to seal a bundle with anything else in there. + +set -euo pipefail + +SRCDIR=$(cd "$(dirname "$0")" && pwd) +IDENTITY="${MACOSX_CODESIGNING_IDENTITY:--}" +ENTITLEMENTS="$SRCDIR/macosx-codesign-entitlements.plist" +KEYCHAIN="${MACOSX_CODESIGNING_KEYCHAIN:-}" +NOTARY_PROFILE="" +HARDENED=no +RELEASE=no +VERIFY_ONLY=no +TARGETS=() + +while [ $# -gt 0 ]; do + case "$1" in + -i|--identity) + [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; exit 2; } + IDENTITY="$2"; shift 2 ;; + -e|--entitlements) + [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; exit 2; } + ENTITLEMENTS="$2"; shift 2 ;; + -k|--keychain) + [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; exit 2; } + KEYCHAIN="$2"; shift 2 ;; + --hardened) HARDENED=yes; shift ;; + --notarize) + [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; exit 2; } + NOTARY_PROFILE="$2"; shift 2 ;; + --release) RELEASE=yes; shift ;; + --verify) VERIFY_ONLY=yes; shift ;; + -h|--help) sed -n '2,33p' "$0"; exit 0 ;; + -*) echo "unknown option: $1" >&2; exit 2 ;; + *) TARGETS+=("$1"); shift ;; + esac +done + +[ ${#TARGETS[@]} -gt 0 ] || { echo "usage: $(basename "$0") [options] <app-or-dmg> ..." >&2; exit 2; } +[ "$IDENTITY" = "-" ] || HARDENED=yes +if [ -n "$NOTARY_PROFILE" ] && [ "$IDENTITY" = "-" ]; then + echo "--notarize needs a Developer ID identity, not an ad-hoc signature" >&2 + exit 2 +fi + +sign_one() { + local path="$1"; shift + local args=(--force --sign "$IDENTITY" --timestamp=none) + if [ "$IDENTITY" != "-" ]; then + args=(--force --sign "$IDENTITY" --timestamp) + fi + if [ -n "$KEYCHAIN" ]; then + args+=(--keychain "$KEYCHAIN") + fi + codesign "${args[@]}" "$@" "$path" +} + +sign_executable() { + local path="$1"; shift + if [ "$HARDENED" = yes ]; then + sign_one "$path" --options runtime --entitlements "$ENTITLEMENTS" "$@" + else + sign_one "$path" "$@" + fi +} + +sign_disk_image() { + local path="$1" + local args=(--force --sign "$IDENTITY" --timestamp) + if [ -n "$KEYCHAIN" ]; then + args+=(--keychain "$KEYCHAIN") + fi + codesign "${args[@]}" "$path" +} + +# notarytool exits 0 even when the notary service rejects the submission, so +# a successful staple is the only proof the ticket exists. A .app cannot be +# submitted as-is; it goes up zipped and the ticket is stapled to the bundle. +notarize() { + local target="$1" upload="$1" zip="" + case "$target" in + *.dmg) ;; + *) + zip=$(mktemp -d)/$(basename "$target").zip + ditto -c -k --keepParent "$target" "$zip" + upload="$zip" ;; + esac + echo "==> notarizing $target (profile: $NOTARY_PROFILE)" + if ! xcrun notarytool submit --wait --keychain-profile "$NOTARY_PROFILE" "$upload"; then + echo "notarization submission failed" >&2 + [ -z "$zip" ] || rm -rf "$(dirname "$zip")" + return 1 + fi + [ -z "$zip" ] || rm -rf "$(dirname "$zip")" + if ! xcrun stapler staple "$target"; then + echo "stapling failed: the submission was probably rejected; run 'xcrun notarytool log' for details" >&2 + return 1 + fi + echo " stapled notarization ticket to $target" + if ! xcrun stapler validate "$target"; then + echo "stapler validate: FAILED (staple reported success but does not validate)" >&2 + return 1 + fi + echo " stapled ticket validates" +} + +# hdiutil create/codesign only ever touch the staged tree before it goes into +# the image; nothing checks the *finished* image's own structure, or that the +# application actually mounts and verifies from inside it. See the "older +# hdiutil makehybrid" note above sign_app() for why that distinction matters. +verify_dmg_contents() { + local dmg="$1" mnt rc=0 app + if ! hdiutil verify "$dmg" >/dev/null; then + echo "hdiutil verify: FAILED" >&2 + return 1 + fi + echo "hdiutil verify: OK" + + mnt=$(hdiutil attach -readonly -nobrowse "$dmg" | tail -1 | awk -F'\t' '{print $NF}') + if [ -z "$mnt" ] || [ ! -d "$mnt" ]; then + echo "could not mount $dmg to verify its contents" >&2 + return 1 + fi + for app in "$mnt"/*.app; do + [ -d "$app" ] || continue + if ! codesign --verify --deep --strict --verbose=2 "$app"; then + echo "enclosed application verify: FAILED ($app)" >&2 + rc=1 + fi + done + hdiutil detach "$mnt" -quiet + if [ "$rc" -eq 0 ]; then + echo " enclosed application verifies from the mounted image" + fi + return "$rc" +} + +report() { + local target="$1" spctl_rc=0 dv + echo "--- $target" + dv=$(codesign -dv --verbose=2 "$target" 2>&1) || true + printf '%s\n' "$dv" | grep -E 'Identifier|Format|CodeDirectory|Authority|TeamIdentifier|Sealed' || true + if ! codesign --verify --deep --strict --verbose=2 "$target"; then + echo "verify: FAILED" >&2 + return 1 + fi + echo "verify: OK" + case "$target" in + *.dmg) + verify_dmg_contents "$target" || return 1 + spctl --assess --type open --context context:primary-signature --verbose=4 "$target" || spctl_rc=$? ;; + *) spctl --assess --type exec --verbose=4 "$target" || spctl_rc=$? ;; + esac + # spctl rejects every ad-hoc signature outright, notarized or not, so only + # a real signature's rejection is meaningful enough to fail the build on. + # Detect that from the target's own signature (an "Authority=" line means a + # real CA-chained identity; ad-hoc has none) rather than the -i/env + # default, so this is also correct when --verify checks an + # already-signed artifact without re-passing -i. + if [ "$spctl_rc" -ne 0 ] && [ "$RELEASE" = yes ] && printf '%s\n' "$dv" | grep -q 'Authority='; then + echo "spctl: FAILED (fatal under --release)" >&2 + return 1 + fi +} + +# codesign rewrites every Mach-O it signs and writes _CodeSignature/ into +# every bundle it seals; the installer stages files read-only. Open just those +# for writing rather than the whole tree, so the staged UnixRights survive. +open_for_signing() { + local b="$1" d + for d in "$b" "$b/Contents" "$b"/Versions/*/; do + if [ -d "$d" ]; then chmod u+w "$d"; fi + done + find "$b" -maxdepth 3 -name _CodeSignature -exec chmod -R u+w {} + 2>/dev/null || true +} + +sign_app() { + local app="$1" + [ -d "$app/Contents" ] && [ -f "$app/Contents/Info.plist" ] || { + echo "not an application bundle: $app" >&2 + return 1 + } + echo "==> signing $app (identity: $IDENTITY, hardened: $HARDENED)" + + # Quarantine and other xattrs make codesign fail or produce an unstable seal. + xattr -cr "$app" 2>/dev/null || true + + # A bundle's main executable is signed as part of its bundle, not on its own: + # codesign silently redirects such a path to the enclosing bundle. + local bundles=() main_execs=() b exe + while IFS= read -r b; do bundles+=("$b"); done < <(find "$app" \ + \( -name '*.app' -o -name '*.framework' -o -name '*.bundle' \ + -o -name '*.mdimporter' -o -name '*.plugin' -o -name '*.qlgenerator' \) \ + | awk '{ print gsub(/\//,"/") "\t" $0 }' | sort -rn | cut -f2-) + for b in ${bundles[@]+"${bundles[@]}"}; do + exe=$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$b/Contents/Info.plist" 2>/dev/null) || continue + main_execs+=("$b/Contents/MacOS/$exe") + done + + # Find every Mach-O with one batched file(1) run instead of a process per + # file (an installation holds ~10k). --print0 emits "path\0: type\n". + local machos=() executable_machos=() f type + while IFS= read -r -d '' f && IFS= read -r type; do + case "$type" in + ": Mach-O"*) + machos+=("$f") + case "$type" in *" executable"*) executable_machos+=("$f") ;; esac + ;; + esac + done < <(find "$app" -type f -print0 | xargs -0 file --no-pad --print0 -- 2>/dev/null) + "$SRCDIR/macosx-check-load-commands.sh" "$app" + # codesign rewrites a Mach-O through a temporary file beside it, so the + # containing directory has to be writable as well. + if [ ${#machos[@]} -gt 0 ]; then + local dirs=() + for f in "${machos[@]}"; do dirs+=("${f%/*}"); done + chmod u+w "${machos[@]}" + printf '%s\n' "${dirs[@]}" | sort -u | tr '\n' '\0' | xargs -0 chmod u+w + fi + for b in ${bundles[@]+"${bundles[@]}"}; do open_for_signing "$b"; done + + # 1. every Mach-O object, deepest path first. Process entitlements belong + # on executables; dylibs and plug-ins inherit the host process's policy. + local count=0 + if [ ${#machos[@]} -gt 0 ]; then + while IFS= read -r f; do + case " ${main_execs[*]-} " in *" $f "*) continue ;; esac + case " ${executable_machos[*]-} " in + *" $f "*) sign_executable "$f" ;; + *) sign_one "$f" ;; + esac + count=$((count + 1)) + done < <(printf '%s\n' "${machos[@]}" | awk '{ print gsub(/\//,"/") "\t" $0 }' | sort -rn | cut -f2-) + fi + echo " signed $count Mach-O objects" + + # 2. nested bundles, deepest first, so each seal covers already-signed contents + for b in ${bundles[@]+"${bundles[@]}"}; do + [ "$b" = "$app" ] && continue + case "$b" in + *.app) sign_executable "$b" ;; + *) sign_one "$b" ;; + esac + echo " sealed nested bundle: ${b#"$app"/}" + done + + # 3. the app bundle itself + sign_executable "$app" + echo " sealed $app" +} + +for target in "${TARGETS[@]}"; do + [ -e "$target" ] || { echo "no such path: $target" >&2; exit 1; } + case "$target" in + *.dmg) + if [ "$VERIFY_ONLY" = yes ]; then report "$target"; continue; fi + # A .dmg is signed as a whole; the .app inside must already be signed. + if [ "$IDENTITY" = "-" ]; then + echo "refusing to ad-hoc sign a .dmg (pointless); pass -i <Developer ID>" >&2 + exit 1 + fi + sign_disk_image "$target" + [ -z "$NOTARY_PROFILE" ] || notarize "$target" + report "$target" + ;; + *) + if [ "$VERIFY_ONLY" = yes ]; then report "$target"; continue; fi + sign_app "$target" + [ -z "$NOTARY_PROFILE" ] || notarize "$target" + # spctl rejects a Developer ID app that isn't notarized yet, so this + # must run after notarize, not inside sign_app() - see the .dmg + # branch above, which already had this order right. + report "$target" + ;; + esac +done diff --git a/release-scripts/macosx-remote-sign.sh b/release-scripts/macosx-remote-sign.sh index 6d9ac20..1f92b22 100755 --- a/release-scripts/macosx-remote-sign.sh +++ b/release-scripts/macosx-remote-sign.sh @@ -15,12 +15,12 @@ # --with-macosx-codesigning-identity, which already produces an ordinary # unsigned .dmg with no build-side changes, and hands it to this script. # -# Deploy this script to the signing host alongside copies of three files from -# openoffice/main/solenv/bin/: macosx-codesign.sh, -# macosx-check-load-commands.sh, macosx-codesign-entitlements.plist. All the -# actual signing/notarizing/verifying is done by macosx-codesign.sh, resolved -# here as a sibling of wherever this script itself was invoked from - no -# OpenOffice source checkout is needed on the signing host. +# Deploy this script together with its siblings macosx-codesign.sh, +# macosx-check-load-commands.sh and macosx-codesign-entitlements.plist, which +# do the actual signing/notarizing/verifying. They are verbatim copies of +# openoffice trunk's main/solenv/bin/ files as of c42fc0a9ce; keep them in +# sync from there rather than editing them here. No OpenOffice source +# checkout is needed on the signing host. # # Not to be confused with this directory's hash-sign.sh, which GPG-signs # release artifacts for Apache distribution integrity (checksums + .asc @@ -41,6 +41,10 @@ # --sha256 HASH verify <input.dmg> against this checksum before # doing anything else (a bare digest or a full # "shasum -a 256" checksum line both work) +# --legacy-layout first move non-code out of Contents/MacOS and loose +# entries out of Contents/ into Contents/Resources, +# leaving symlinks, and drop dangling symlinks: 4.1.x +# bundles cannot be sealed otherwise # -h, --help # # Known limitation: the dmg is rebuilt with a plain "hdiutil create", without @@ -61,6 +65,7 @@ KEYCHAIN="" ENTITLEMENTS="" NOTARY_PROFILE="" RELEASE=no +LEGACY_LAYOUT=no NON_RELEASE=no EXPECT_SHA256="" SRC_DMG="" @@ -82,10 +87,11 @@ while [ $# -gt 0 ]; do NOTARY_PROFILE="$2"; shift 2 ;; --release) RELEASE=yes; shift ;; --non-release) NON_RELEASE=yes; shift ;; + --legacy-layout) LEGACY_LAYOUT=yes; shift ;; --sha256) [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; exit 2; } EXPECT_SHA256="$2"; shift 2 ;; - -h|--help) sed -n '2,52p' "$0"; exit 0 ;; + -h|--help) sed -n '2,56p' "$0"; exit 0 ;; -*) echo "unknown option: $1" >&2; exit 2 ;; *) if [ -z "$SRC_DMG" ]; then SRC_DMG="$1" @@ -131,14 +137,14 @@ fi MOUNT_POINT="" STAGING_DIR="" -TEMP_DMG="" +TEMP_DIR="" cleanup() { if [ -n "$MOUNT_POINT" ]; then hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null || true rmdir "$MOUNT_POINT" 2>/dev/null || true fi [ -z "$STAGING_DIR" ] || rm -rf "$STAGING_DIR" - [ -z "$TEMP_DMG" ] || rm -f "$TEMP_DMG" + [ -z "$TEMP_DIR" ] || rm -rf "$TEMP_DIR" } trap cleanup EXIT @@ -170,6 +176,56 @@ require_developer_id_signature() { } } +is_macho() { + [ -f "$1" ] && [ ! -L "$1" ] && file -b "$1" | grep '^Mach-O' >/dev/null +} + +# NUL-separated: what codesign refuses to seal in Contents/MacOS, i.e. every +# entry other than Mach-O files, symlinks and the bundle's main executable. +non_code_in_macos() { + local app="$1" exe e + [ -d "$app/Contents/MacOS" ] || return 0 + exe=$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$app/Contents/Info.plist" 2>/dev/null) || exe="" + while IFS= read -r -d '' e; do + [ ! -L "$e" ] && [ "${e##*/}" != "$exe" ] && ! is_macho "$e" || continue + printf '%s\0' "$e" + done < <(find "$app/Contents/MacOS" -mindepth 1 -maxdepth 1 -print0) +} + +# Relative symlinks keep every path the 4.1.x runtime uses resolving to the +# same file, while codesign only sees code in Contents/MacOS. +relayout_legacy_app() { + local app="$1" contents="$1/Contents" e n + /usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$contents/Info.plist" >/dev/null || { + echo "no CFBundleExecutable in $contents/Info.plist" >&2 + return 1 + } + # codesign --strict rejects them, and they resolve to nothing anyway. + find "$app" -type l ! -exec test -e {} \; -print -delete | sed 's/^/ removed dangling symlink: /' + chmod u+w "$contents" "$contents/MacOS" "$contents/Resources" + mkdir -p "$contents/Resources/ooo-program" "$contents/Resources/ooo-contents" + while IFS= read -r -d '' e; do + n="${e##*/}" + if [ -d "$e" ] && find "$e" -type f -print0 | xargs -0 file --no-pad -- 2>/dev/null | grep ': Mach-O' >/dev/null; then + echo "legacy layout: $e holds code; refusing to move it into Resources" >&2 + return 1 + fi + [ ! -e "$contents/Resources/ooo-program/$n" ] || { echo "legacy layout: $n already in Resources/ooo-program" >&2; return 1; } + mv "$e" "$contents/Resources/ooo-program/$n" + ln -s "../Resources/ooo-program/$n" "$e" + done < <(non_code_in_macos "$app") + while IFS= read -r -d '' e; do + n="${e##*/}" + [ ! -L "$e" ] || continue + case "$n" in + Info.plist|PkgInfo|MacOS|Resources|Frameworks|PlugIns|Library|SharedSupport|_CodeSignature) continue ;; + esac + [ ! -e "$contents/Resources/ooo-contents/$n" ] || { echo "legacy layout: $n already in Resources/ooo-contents" >&2; return 1; } + mv "$e" "$contents/Resources/ooo-contents/$n" + ln -s "Resources/ooo-contents/$n" "$e" + done < <(find "$contents" -mindepth 1 -maxdepth 1 -print0) +} + if [ -n "$EXPECT_SHA256" ]; then echo "==> verifying checksum of $SRC_DMG" # Accept either a bare digest or a full checksum line as written by @@ -190,15 +246,25 @@ VOLUME_NAME=$(diskutil info "$MOUNT_POINT" | awk '/Volume Name/{sub(/^[^:]*: +/, [ -n "$VOLUME_NAME" ] || VOLUME_NAME=$(basename "$MOUNT_POINT") STAGING_DIR=$(mktemp -d) -echo "==> copying volume contents to $STAGING_DIR" +# ditto into a not-yet-existing directory so it takes the volume root's mode; +# the 0700 mktemp directory would otherwise become the rebuilt volume's root. +VOLUME_DIR="$STAGING_DIR/volume" +echo "==> copying volume contents to $VOLUME_DIR" # Copy everything on the volume, not just the .app: an install DMG normally # also carries an Applications symlink, license/readme folders, and a # background image, all of which the rebuilt dmg below should keep too. -ditto "$MOUNT_POINT" "$STAGING_DIR" +ditto "$MOUNT_POINT" "$VOLUME_DIR" +# Shipped dmgs put FinderInfo on read-only files, which macosx-codesign.sh's +# xattr -cr cannot strip and codesign rejects; recopy the app without it. +for app in "$VOLUME_DIR"/*.app; do + [ -d "$app" ] || continue + rm -rf "$app" + ditto --norsrc --noextattr --noqtn "$MOUNT_POINT/${app##*/}" "$app" +done detach_mounted -apps=("$STAGING_DIR"/*.app) +apps=("$VOLUME_DIR"/*.app) [ -d "${apps[0]}" ] || { echo "no .app bundle found in $SRC_DMG" >&2; exit 1; } [ ${#apps[@]} -eq 1 ] || { echo "expected exactly one .app in $SRC_DMG, found ${#apps[@]}:" >&2 @@ -207,6 +273,20 @@ apps=("$STAGING_DIR"/*.app) } APP="${apps[0]}" +if [ "$LEGACY_LAYOUT" = yes ]; then + echo "==> moving non-code out of $APP/Contents/MacOS" + relayout_legacy_app "$APP" +else + non_code=() + while IFS= read -r -d '' e; do non_code+=("${e#"$APP"/}"); done < <(non_code_in_macos "$APP") + [ ${#non_code[@]} -eq 0 ] || { + echo "$APP/Contents/MacOS holds ${#non_code[@]} non-code entries, which codesign will not seal:" >&2 + printf ' %s\n' "${non_code[@]:0:10}" >&2 + echo "this is the layout of 4.1.x and 4.2.0 dev builds; rerun with --legacy-layout" >&2 + exit 1 + } +fi + sign_args=(-i "$IDENTITY") [ -z "$KEYCHAIN" ] || sign_args+=(-k "$KEYCHAIN") [ -z "$ENTITLEMENTS" ] || sign_args+=(-e "$ENTITLEMENTS") @@ -219,11 +299,13 @@ require_developer_id_signature "$APP" echo "==> building $OUT_DMG (volume: $VOLUME_NAME)" mkdir -p "$(dirname "$OUT_DMG")" -# Build and sign the image under a temp name in the output directory (same +# Build and sign the image in a temp directory beside the output (same # filesystem, so the final mv is atomic) and only publish it once the signing -# succeeds: a failure here must not leave an unsigned image at $OUT_DMG. -TEMP_DMG="$OUT_DMG.tmp.$$.dmg" -hdiutil create -srcfolder "$STAGING_DIR" -volname "$VOLUME_NAME" -fs HFS+ -format UDZO -ov "$TEMP_DMG" +# succeeds: a failure here must not leave an unsigned image at $OUT_DMG. It +# keeps the final file name because codesign takes the dmg's identifier from it. +TEMP_DIR=$(mktemp -d "$(dirname "$OUT_DMG")/.macosx-remote-sign.XXXXXX") +TEMP_DMG="$TEMP_DIR/$(basename "$OUT_DMG")" +hdiutil create -srcfolder "$VOLUME_DIR" -volname "$VOLUME_NAME" -fs HFS+ -format UDZO -ov "$TEMP_DMG" echo "==> signing $TEMP_DMG" "$CODESIGN" "${sign_args[@]}" "$TEMP_DMG" @@ -242,6 +324,7 @@ if [ -n "$NOTARY_PROFILE" ]; then fi mv -f "$TEMP_DMG" "$OUT_DMG" -TEMP_DMG="" +rmdir "$TEMP_DIR" +TEMP_DIR="" echo "==> done: $OUT_DMG" diff --git a/release-scripts/tests/conftest.py b/release-scripts/tests/conftest.py index fd71d16..55771db 100644 --- a/release-scripts/tests/conftest.py +++ b/release-scripts/tests/conftest.py @@ -62,10 +62,27 @@ def make_dmg(src: Path, volname: str, out: Path) -> None: assert result.returncode == 0, result.stderr -def fixture_dmg(root: Path, name: str, app_count: int = 1, extras: bool = True) -> Path: +FINDER_INFO_HEX = "00" * 10 + "FFFFFFFF" + "00" * 18 + + +def set_finder_info(path: Path) -> None: + r = _run(["xattr", "-wx", "com.apple.FinderInfo", FINDER_INFO_HEX, str(path)]) + assert r.returncode == 0, r.stderr + + +def fixture_dmg( + root: Path, + name: str, + app_count: int = 1, + extras: bool = True, + root_mode: int | None = None, + finder_info: bool = False, +) -> Path: """Build an install-dmg-like fixture and return the path to its in.dmg.""" src = root / name / "src" src.mkdir(parents=True) + if root_mode is not None: + src.chmod(root_mode) for i in range(1, app_count + 1): app = src / f"App{i}.app" / "Contents" app.mkdir(parents=True) @@ -77,6 +94,14 @@ def fixture_dmg(root: Path, name: str, app_count: int = 1, extras: bool = True) # A nested .DS_Store survives dmg (re)creation; a root one written as a # plain file does not (hdiutil create drops it). See REVIEW F7. (src / "READMEs" / ".DS_Store").write_text("finder-layout\n") + if finder_info: + # Shipped AOO dmgs carry FinderInfo on read-only files, which xattr -c + # cannot strip and codesign rejects as "detritus". + lib = src / "App1.app" / "Contents" / "lib.dylib" + lib.write_text("not really a dylib\n") + set_finder_info(lib) + lib.chmod(0o444) + set_finder_info(src / "READMEs") out = root / name / "in.dmg" make_dmg(src, f"remote-sign-{name}", out) return out @@ -208,3 +233,64 @@ def volume_name(dmg: Path) -> str: if line.strip().startswith("Volume Name:"): return line.split(":", 1)[1].strip() raise AssertionError(f"no volume name for {dmg}") + + +def xattrs(path: Path) -> list[str]: + r = _run(["xattr", str(path)]) + assert r.returncode == 0, r.stderr + return r.stdout.split() + + +INFO_PLIST = """<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"><dict><key>CFBundleExecutable</key><string>soffice</string></dict></plist> +""" + + +def legacy_fixture_dmg(root: Path, name: str, code_in_subdir: bool = False) -> Path: + """An install dmg whose .app has the 4.1.x layout: data files and folders + in Contents/MacOS and loose files directly in Contents/.""" + src = root / name / "src" + contents = src / "OpenOffice.app" / "Contents" + macos = contents / "MacOS" + macos.mkdir(parents=True) + (contents / "Info.plist").write_text(INFO_PLIST) + (contents / "NOTICE").write_text("notice\n") + (contents / "share").mkdir() + (contents / "share" / "x.xcd").write_text("share\n") + (contents / "Library").mkdir() + (contents / "Resources").mkdir() + (contents / "program").symlink_to("MacOS") + # The launcher is the bundle's main executable; stays put whatever it is. + (macos / "soffice").write_text("#!/bin/sh\n") + shutil.copy("/usr/bin/true", macos / "libcode.dylib") + (macos / "unorc").write_text("unorc\n") + (macos / "unorc").chmod(0o444) + (macos / "startup.sh").write_text("#!/bin/sh\n") + (macos / "regcomp").symlink_to("startup.sh") + (macos / "urelibs").symlink_to("../basis-link/ure-link/lib") + (macos / "addin").mkdir() + (macos / "addin" / "a.rdb").write_text("addin\n") + if code_in_subdir: + shutil.copy("/usr/bin/true", macos / "addin" / "helper") + (src / "Applications").symlink_to("/Applications") + out = root / name / "in.dmg" + make_dmg(src, f"remote-sign-{name}", out) + return out + + +def modern_fixture_dmg(root: Path, name: str) -> Path: + """An install dmg whose .app has trunk's layout: only code in Contents/MacOS.""" + src = root / name / "src" + contents = src / "OpenOffice.app" / "Contents" + macos = contents / "MacOS" + macos.mkdir(parents=True) + (contents / "Info.plist").write_text(INFO_PLIST) + (contents / "Resources").mkdir() + (contents / "Resources" / "unorc").write_text("unorc\n") + (macos / "soffice").write_text("#!/bin/sh\n") + shutil.copy("/usr/bin/true", macos / "libcode.dylib") + (macos / "libcode.1.dylib").symlink_to("libcode.dylib") + out = root / name / "in.dmg" + make_dmg(src, f"remote-sign-{name}", out) + return out diff --git a/release-scripts/tests/test_macosx_remote_sign.py b/release-scripts/tests/test_macosx_remote_sign.py index d8ad1f3..b210fca 100644 --- a/release-scripts/tests/test_macosx_remote_sign.py +++ b/release-scripts/tests/test_macosx_remote_sign.py @@ -23,11 +23,12 @@ import hashlib import os +import stat import subprocess import pytest -from conftest import IDENTITY, Signer, fixture_dmg, mount_point, requires_macos, volume_name +from conftest import IDENTITY, SCRIPT_DIR, Signer, legacy_fixture_dmg, modern_fixture_dmg, xattrs, fixture_dmg, mount_point, requires_macos, volume_name pytestmark = requires_macos @@ -233,6 +234,17 @@ def test_normal_run_publishes_output_and_no_temp(signer, workdir): assert r.returncode == 0, r.stderr assert out.exists() assert list(signer.path.glob("out.dmg.tmp.*")) == [] + assert list(signer.path.glob(".macosx-remote-sign.*")) == [] + + +def test_dmg_signed_under_final_name(signer, workdir): + """codesign takes a dmg's signing identifier from its file name.""" + src = fixture_dmg(workdir, "args") + out = signer.path / "final-name.dmg" + assert signer.run_non_release("-i", IDENTITY, src, out).returncode == 0 + dmg_signs = [l for l in signer.log.read_text().splitlines() if l.endswith(".dmg")] + assert len(dmg_signs) == 1 + assert dmg_signs[0].endswith("/final-name.dmg") def test_release_forwards_options_and_validates_enclosed_staple(signer, workdir): @@ -291,6 +303,7 @@ def test_non_developer_id_signature_is_not_published(signer, workdir, env): assert "not signed with a Developer ID Application certificate" in r.stderr assert not out.exists() assert list(signer.path.glob("wrong-identity.dmg.tmp.*")) == [] + assert list(signer.path.glob(".macosx-remote-sign.*")) == [] def test_enclosed_staple_failure_is_not_published(signer, workdir): @@ -309,6 +322,7 @@ def test_enclosed_staple_failure_is_not_published(signer, workdir): assert r.returncode != 0 assert not out.exists() assert list(signer.path.glob("bad-staple.dmg.tmp.*")) == [] + assert list(signer.path.glob(".macosx-remote-sign.*")) == [] def test_whole_volume_survives(signer, workdir): @@ -322,6 +336,32 @@ def test_whole_volume_survives(signer, workdir): assert (mp / "App1.app").is_dir() [email protected]("mode", [0o775, 0o755]) +def test_volume_root_mode_preserved(signer, workdir, mode): + src = fixture_dmg(workdir, "args", root_mode=mode) + out = signer.path / "out.dmg" + assert signer.run_non_release("-i", IDENTITY, src, out).returncode == 0 + with mount_point(src) as mp: + assert stat.S_IMODE(mp.stat().st_mode) == mode + with mount_point(out) as mp: + assert stat.S_IMODE(mp.stat().st_mode) == mode + + +def test_app_finder_info_stripped_rest_kept(signer, workdir): + src = fixture_dmg(workdir, "args", finder_info=True) + out = signer.path / "out.dmg" + with mount_point(src) as mp: + lib = mp / "App1.app" / "Contents" / "lib.dylib" + assert "com.apple.FinderInfo" in xattrs(lib) + r = signer.run_non_release("-i", IDENTITY, src, out) + assert r.returncode == 0, r.stderr + with mount_point(out) as mp: + lib = mp / "App1.app" / "Contents" / "lib.dylib" + assert xattrs(lib) == [] + assert stat.S_IMODE(lib.stat().st_mode) == 0o444 + assert "com.apple.FinderInfo" in xattrs(mp / "READMEs") + + def test_original_volume_name_reused(signer, workdir): src = fixture_dmg(workdir, "args") out = signer.path / "out.dmg" @@ -362,6 +402,7 @@ def test_dmg_sign_failure_writes_nothing(tmp_path, workdir): assert r.returncode != 0 assert not out.exists() assert list(signer.path.glob("final.dmg.tmp.*")) == [] + assert list(signer.path.glob(".macosx-remote-sign.*")) == [] def test_app_sign_failure_writes_nothing(tmp_path, workdir): @@ -390,3 +431,89 @@ def test_spaces_in_paths(tmp_path, workdir): r = signer.run_non_release("-i", IDENTITY, spaced_in, out) assert r.returncode == 0, r.stderr assert out.exists() + + +# ---------------------------------------------------------- legacy layout + + +def test_legacy_layout_moves_non_code_out_of_macos(signer, workdir): + src = legacy_fixture_dmg(workdir, "legacy") + out = signer.path / "out.dmg" + r = signer.run_non_release("--legacy-layout", "-i", IDENTITY, src, out) + assert r.returncode == 0, r.stderr + with mount_point(out) as mp: + contents = mp / "OpenOffice.app" / "Contents" + macos = contents / "MacOS" + assert os.readlink(macos / "unorc") == "../Resources/ooo-program/unorc" + assert (macos / "unorc").read_text() == "unorc\n" + assert stat.S_IMODE((contents / "Resources" / "ooo-program" / "unorc").stat().st_mode) == 0o444 + assert os.readlink(macos / "addin") == "../Resources/ooo-program/addin" + assert (macos / "addin" / "a.rdb").read_text() == "addin\n" + assert os.readlink(macos / "startup.sh") == "../Resources/ooo-program/startup.sh" + assert os.readlink(macos / "regcomp") == "startup.sh" + assert (macos / "regcomp").is_file() + assert not (macos / "urelibs").is_symlink() + for kept in ("soffice", "libcode.dylib"): + assert (macos / kept).is_file() and not (macos / kept).is_symlink(), kept + assert os.readlink(contents / "NOTICE") == "Resources/ooo-contents/NOTICE" + assert (contents / "share" / "x.xcd").read_text() == "share\n" + assert os.readlink(contents / "program") == "MacOS" + for kept in ("Info.plist", "Library", "Resources"): + assert not (contents / kept).is_symlink(), kept + + +def test_legacy_layout_without_flag_stops_before_signing(signer, workdir): + src = legacy_fixture_dmg(workdir, "legacy") + out = signer.path / "out.dmg" + r = signer.run_non_release("-i", IDENTITY, src, out) + assert r.returncode == 1 + assert "--legacy-layout" in r.stderr + assert "unorc" in r.stderr and "addin" in r.stderr + assert "libcode.dylib" not in r.stderr and "soffice" not in r.stderr + assert not out.exists() + assert signer.events.read_text() == "" + + +def test_modern_layout_signs_untouched_without_flag(signer, workdir): + src = modern_fixture_dmg(workdir, "modern") + out = signer.path / "out.dmg" + r = signer.run_non_release("-i", IDENTITY, src, out) + assert r.returncode == 0, r.stderr + with mount_point(out) as mp: + contents = mp / "OpenOffice.app" / "Contents" + assert not (contents / "Resources" / "ooo-program").exists() + assert (contents / "MacOS" / "soffice").is_file() + assert os.readlink(contents / "MacOS" / "libcode.1.dylib") == "libcode.dylib" + + +def test_legacy_layout_refuses_code_in_moved_folder(signer, workdir): + src = legacy_fixture_dmg(workdir, "legacy", code_in_subdir=True) + out = signer.path / "out.dmg" + r = signer.run_non_release("--legacy-layout", "-i", IDENTITY, src, out) + assert r.returncode == 1 + assert "addin" in r.stderr + assert not out.exists() + assert "sign-app" not in signer.events.read_text() + + +# ------------------------------------------------------- bundled delegate + + +def test_bundled_delegate_accepts_forwarded_options(workdir): + """The real sibling macosx-codesign.sh must parse every option the wrapper + forwards; a missing path makes it stop right after option parsing.""" + delegate = SCRIPT_DIR / "macosx-codesign.sh" + missing = workdir / "missing.dmg" + r = subprocess.run( + [str(delegate), "-i", IDENTITY, "-k", "k", "-e", "e", + "--notarize", "p", "--release", str(missing)], + capture_output=True, text=True, + ) + assert r.returncode == 1, r.stderr + assert f"no such path: {missing}" in r.stderr + + +def test_bundled_delegate_siblings_present(): + for name in ("macosx-codesign.sh", "macosx-check-load-commands.sh"): + assert os.access(SCRIPT_DIR / name, os.X_OK), name + assert (SCRIPT_DIR / "macosx-codesign-entitlements.plist").is_file()
