diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dc8d37..fe78001 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,29 @@ jobs: - name: Run macOS tests run: sudo ./testing/test_install_certs_macos.sh + test-macos-jvm: + name: Test (macOS JVM) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # Pass JAVA_HOME and PATH explicitly to the sudo'd test runner rather + # than relying on `sudo --preserve-env=…`: future actions/setup-java + # versions could rename or rescope the JAVA_HOME env var, silently + # breaking the preserve-by-name approach. Explicit `env VAR=…` is + # the same idiom and survives such churn. + - name: Run macOS JVM smoke matrix + run: | + sudo env \ + JAVA_HOME="$JAVA_HOME" \ + PATH="$JAVA_HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + ./testing/test_install_certs_jvm_macos.sh + test-windows: name: Test (Windows) runs-on: windows-latest diff --git a/README.md b/README.md index 02fff5f..6bdaa67 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-11 |--------|----------|---------| | **install_certs_macos.sh** | macOS | Install cert, set env vars (Node/Python), and clear Docker Hub credentials | | **validate_install_macos.sh** | macOS | Validate PEM and env config | +| **install_certs_jvm_macos.sh** | macOS (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS + per-user LaunchAgent setting `JAVA_TOOL_OPTIONS` | +| **validate_certs_jvm_macos.sh** | macOS (JVM) | Validate JVM truststore install (JKS subject + plist + `launchctl getenv`) | +| **_jvm_macos_paths.sh** | macOS (JVM) | Shared constants sourced by both installer and validator. Not directly executable. | | **install_certs_debian_ubuntu.sh** | Debian/Ubuntu | Install cert into system trust + profile.d + user shell rc + Docker cleanup | | **validate_certs_debian_ubuntu.sh** | Debian/Ubuntu | Validate PEM and env config | | **install_certs_jvm_linux.sh** | Linux (JVM) | Install CA for Maven/Gradle/sbt/Ivy: RHEL family → `update-ca-trust extract` into system anchors; others → per-host JKS + `JAVA_TOOL_OPTIONS` in `/etc/environment` | @@ -317,6 +320,100 @@ Users must open a **new terminal** (or `source ~/.zshrc`) for the new environmen --- +## macOS (JVM): install_certs_jvm_macos.sh + +### Overview + +`install_certs_jvm_macos.sh` wires a custom CA certificate into the JVM trust path on macOS so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_macos.sh` if you need those. + +Single path on macOS — there is no OS-trust fallback because macOS-specific `KeychainStore` is broken per [JDK-8321045](https://bugs.openjdk.org/browse/JDK-8321045). The script: + +1. Builds a per-user JKS truststore at `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks` containing only the customer CA. +2. Writes a per-user LaunchAgent plist at `~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist` that calls `launchctl setenv JAVA_TOOL_OPTIONS=…` at `RunAtLoad`. +3. Bootstraps the agent into `gui/` via `launchctl bootstrap` so the env var becomes available to every subsequently-launched GUI process (Dock-launched IntelliJ, JetBrains Toolbox, `open -a …`). + +The `~/.zshrc` / `~/.bash_profile` shortcut is deliberately NOT used: it silently fails for Dock-launched IDE builds because GUI apps don't read the shell's interactive init. The LaunchAgent is the only recipe verified to reach Dock-launched and `open`-launched GUI applications, which inherit `JAVA_TOOL_OPTIONS` from the launchd `gui/` domain. Terminal sessions inherit transitively because Terminal.app itself is launchd-spawned. + +### Requirements + +- **macOS**. +- **Root** (`sudo`) — needed to chown per-user files and to bootstrap into other users' `gui/` domains under `--all-users`. +- **`openssl`** on `PATH` (ships with macOS as LibreSSL; full OpenSSL via Homebrew also works). +- **`keytool`** on `PATH` (provided by any JDK — Homebrew openjdk, Adoptium Temurin, JetBrains JBR, etc.). +- macOS built-ins used by the installer (all preinstalled on supported macOS versions): `plutil` (LaunchAgent plist validation), `launchctl` (bootstrap into `gui/`), `dscl` (user / home lookup), `stat` (UID-based filtering under `--all-users`). + +### Options + +| Option | Required | Description | +|--------|----------|-------------| +| `--use-cert ` | **Yes** | Path to an existing PEM/CRT certificate file. Validation: parseable X.509, not expired, `CA:TRUE` in basicConstraints. Bundles emit a warning (only the first cert imports). | +| `--cert-name ` | No (default: `package-route-custom-ca`) | Base name for the JKS alias. Must match `[A-Za-z0-9._-]+`. Pass the same value to the validator (the validator matches by subject so this is informational unless multiple CAs coexist). | +| `--all-users` | No | Iterate `/Users/*` and install the LaunchAgent + JKS for every account with UID ≥ 501. Default = only `SUDO_USER` (or the GUI console user under JAMF). | +| `-h`, `--help` | — | Usage. | + +### Examples + +```bash +# Single user (typical: install for the developer running sudo) +sudo ./install_certs_jvm_macos.sh --use-cert /tmp/ZscalerRoot0.pem + +# Fleet onboarding (shared Mac with multiple accounts) +sudo ./install_certs_jvm_macos.sh --use-cert /tmp/ZscalerRoot0.pem --all-users + +# Custom alias for the JKS +sudo ./install_certs_jvm_macos.sh --use-cert /tmp/ZscalerRoot0.pem --cert-name zscaler-root +``` + +### Validation: validate_certs_jvm_macos.sh + +**`--expected-subject` is required.** Per user, asserts: +- JKS file exists at the per-user path. +- `keytool -list -v` shows an `Owner:` line matching the substring (case-insensitive). +- LaunchAgent plist exists and passes `plutil -lint`. +- `launchctl getenv JAVA_TOOL_OPTIONS` in `gui/` returns the JKS path (warn-not-fail when the user is not in an active GUI session — the plist will load at next login). + +```bash +./validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" +sudo ./validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" --all-users +``` + +`--all-users` requires root (other users' `~/Library` is `0700`). Exit code 0 if all checks pass, 1 otherwise. + +### Caveats + +- **Already-running apps must be restarted.** macOS does not re-poll the launchd domain env on Cmd-Tab. Quit and relaunch IntelliJ / your IDE after install for the env var to take effect. +- **Gradle Daemon caching.** Run `gradle --stop` after onboarding so the daemon re-reads `JAVA_TOOL_OPTIONS` at next start. +- **`Picked up JAVA_TOOL_OPTIONS:` banner.** Every JVM startup prints this to stderr. CI parsers that strict-match empty-stderr need to tolerate it. +- **`changeit` truststore password.** OpenJDK convention; *not* a secret. The JKS holds only public CA certificates and the password protects file integrity, not contents. +- **JKS extends the JDK's bundled cacerts.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The installer copies `$JAVA_HOME/lib/security/cacerts` to `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks` first, then `keytool -importcert` appends the corporate CA. The resulting store has ~150 public roots **plus** the corporate one. +- **`JAVA_TOOL_OPTIONS` inner quoting.** The JKS path lives under `~/Library/Application Support/` which contains a space; the JVM tokenises `JAVA_TOOL_OPTIONS` on whitespace and only honours embedded `"…"` grouping. The installer therefore writes `-Djavax.net.ssl.trustStore="" -Djavax.net.ssl.trustStorePassword="changeit"` into the LaunchAgent plist so the literal quotes reach the JVM tokenizer. Older installs (pre-fix) that wrote the unquoted form produced a fatal `Unrecognized option` on every Dock-launched JVM. +- **`gui/` domain only exists for logged-in GUI users.** Under `--all-users`, accounts that are not currently logged in get the plist installed but `launchctl bootstrap` is soft-skipped; launchd loads the plist automatically at their next login. +- **JAMF / headless kiosk caveat.** On a Mac running JAMF policies *before* any user has logged in, `gui/` is not yet running, so `launchctl bootstrap gui/` either fails or loads into a non-running domain. The plist will load on first interactive login. For truly headless boxes (rack-mounted Mac mini build agents), pair this installer with a `/Library/LaunchDaemons` (system-scope) trust-bootstrap before the first user login, or run the installer interactively as part of provisioning. +- **`KeychainStore` truststoreType is rejected.** Broken per JDK-8321045 — incomplete for `SystemRootCertificates.keychain`. No OS-trust fallback on macOS. +- **`~/.zshrc` / `~/.bash_profile` are deliberately NOT touched.** They silently fail for Dock-launched IDE builds — see Overview. +- **IntelliJ per-IDE SSL store** (`~/Library/Application Support/JetBrains/IntelliJIdea/ssl/cacerts`) is a different layer (plugin marketplace, VCS). Not configured by this script. + +### Testing + +`./testing/test_install_certs_jvm_macos.sh` runs the 9-invariant smoke matrix locally and on CI. Each run targets `SUDO_USER`'s per-user files and cleans up between cases via `trap EXIT`. + +```bash +# Local +sudo ./testing/test_install_certs_jvm_macos.sh +``` + +The same matrix runs on every push and pull request via `.github/workflows/ci.yml` (`test-macos-jvm` job). + +### Summary (macOS JVM) + +- **One run as root**, single cert source via `--use-cert`. +- Per-user JKS at `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks`. +- Per-user LaunchAgent at `~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist` bootstrapped into `gui/`. +- **Idempotent**, **re-runnable**, **JDK-version-agnostic**. New JDK installs do not require re-running the script. +- Restart already-running apps; `gradle --stop` for the Gradle Daemon. + +--- + ## Linux (Debian/Ubuntu): install_certs_debian_ubuntu.sh ### Overview @@ -564,6 +661,7 @@ On **push** and **pull request** to `main` or `master`, GitHub Actions runs: | Job | Runner | Command | |-----|--------|---------| | Test (macOS) | `macos-latest` | `sudo ./testing/test_install_certs_macos.sh` | +| Test (macOS JVM) | `macos-latest` | `sudo ./testing/test_install_certs_jvm_macos.sh` | | Test (Windows) | `windows-latest` | `./testing/test_install_certs_windows.ps1` (PowerShell) | | Test (Linux JVM) | `ubuntu-latest` | `./testing/test_install_certs_jvm_linux.sh` | diff --git a/_jvm_macos_paths.sh b/_jvm_macos_paths.sh new file mode 100644 index 0000000..b7979da --- /dev/null +++ b/_jvm_macos_paths.sh @@ -0,0 +1,42 @@ +# (c) JFrog Ltd. (2026) +# Shared constants for install_certs_jvm_macos.sh and validate_certs_jvm_macos.sh. +# Sourced — must NOT be executed directly. Has no shebang on purpose. +# +# Both scripts read this file via: +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# . "${SCRIPT_DIR}/_jvm_macos_paths.sh" +# +# Keep installer and validator in lockstep by changing only this file. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# _jvm_linux_paths.sh — system anchor (Path A) / JKS+JTO (Path B) +# _jvm_windows_paths.ps1 — per-user JKS under %LOCALAPPDATA% + +# Default base name used as the JKS alias inside the per-user truststore. +# Overridable via --cert-name on the installer (affects ONLY the alias name +# visible in `keytool -list` output — the JKS file path, plist path, and +# LaunchAgent label are all fixed per-user, so a different --cert-name on +# re-run replaces the previous CA rather than installing alongside it). +JVM_MACOS_DEFAULT_CERT_BASENAME="package-route-custom-ca" + +# Per-user JKS truststore. macOS convention: per-user resources under the user's +# Library directory so each account stays isolated. Matches the existing +# install_certs_macos.sh pattern (per-user PEM under ~//). +JKS_RELATIVE_DIR="Library/Application Support/JFrog/package-route-jvm" +JKS_BASENAME="truststore.jks" + +# Per-user LaunchAgent that calls `launchctl setenv JAVA_TOOL_OPTIONS=…` at +# RunAtLoad. This is the only macOS recipe that reaches Dock-launched IDEs; +# the ~/.zshrc shortcut silently fails for GUI-spawned subprocesses. +LAUNCH_AGENT_RELATIVE_DIR="Library/LaunchAgents" +LAUNCH_AGENT_LABEL="com.jfrog.package-reroute.jto-env" +LAUNCH_AGENT_BASENAME="${LAUNCH_AGENT_LABEL}.plist" + +# OpenJDK convention for cacerts and similar truststores. NOT a secret in +# this script's use case: we import only `trustedCertEntry` records (public +# CA certs), so the password protects file *integrity* via the keystore MAC +# but not any private key material. (A JKS that ever holds a PrivateKeyEntry +# would additionally rely on this password to encrypt the key — not relevant +# here.) Persisted in the LaunchAgent plist via -Djavax.net.ssl.trustStorePassword +# so unattended JVMs can open the store. +JKS_PASSWORD="changeit" diff --git a/install_certs_jvm_macos.sh b/install_certs_jvm_macos.sh new file mode 100755 index 0000000..d29a83d --- /dev/null +++ b/install_certs_jvm_macos.sh @@ -0,0 +1,664 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Install a custom CA certificate on macOS for JVM clients (Maven, Gradle, sbt, +# Apache Ivy). +# +# Single path: build a per-user JKS truststore at +# ~/Library/Application Support/JFrog/package-route-jvm/truststore.jks +# containing only the customer CA, then install a per-user LaunchAgent at +# ~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist +# that runs `launchctl setenv JAVA_TOOL_OPTIONS=…` at RunAtLoad. This is the +# ONLY recipe that reaches Dock-launched IDE builds — the ~/.zshrc shortcut +# silently fails for GUI-spawned subprocesses, and macOS-specific +# KeychainStore is broken per JDK-8321045. +# +# Run: +# sudo bash install_certs_jvm_macos.sh --use-cert /path/to/cert.pem +# [--cert-name ] [--all-users] +# +# Notes: +# - macOS only. +# - Must run as root (so per-user files can be chown'd to the target user). +# - JVM trust only — does not configure npm/Python/HF and does not touch +# Docker credentials. Pair with install_certs_macos.sh if you need those. +# - Existing Dock-launched apps must be restarted after install: macOS does +# not re-poll the launchd domain env on app relaunch unless the agent +# was bootstrapped before app launch. +# - JAMF / kiosk caveat: gui/ is the GUI-session launchd domain. On a +# host with NO user logged in (mac-mini in a rack, fresh JAMF bootstrap +# before first login), `launchctl bootstrap gui/` either fails or +# loads into a non-running domain. In that case the agent loads on next +# interactive login. Operators provisioning headless machines should +# also seed the JKS via a separate non-GUI mechanism (e.g. system-wide +# /Library/LaunchDaemons running as root) before any user logs in. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# install_certs_jvm_linux.sh — update-ca-trust OR JKS+JAVA_TOOL_OPTIONS +# install_certs_jvm_windows.ps1 — HKCU\Environment + per-user JKS +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "${SCRIPT_DIR}/_jvm_macos_paths.sh" + +USE_CERT="" +ALL_USERS=0 +CERT_BASENAME="${JVM_MACOS_DEFAULT_CERT_BASENAME}" + +usage() { + cat < [--cert-name ] [--all-users] + +Options: + --use-cert Path to an existing PEM/CRT certificate file (required). + --cert-name Alias under which the CA is stored inside the JKS + truststore (default: ${CERT_BASENAME}). Cosmetic — affects + only \`keytool -list\` output. JKS path, plist path, and + LaunchAgent label are fixed per-user. + --all-users Iterate /Users/* (UID >= 501, skip Shared) and install + a LaunchAgent + JKS for every account. Default = only + SUDO_USER (or the console-user under JAMF). + -h, --help Show this help. + +Note: unlike the Linux sibling, macOS has only one install path (JKS + +per-user LaunchAgent setting JAVA_TOOL_OPTIONS). There is no --mode flag +because the KeychainStore truststoreType is broken (JDK-8321045) and no +OS-trust fallback exists. + +Examples: + sudo $0 --use-cert /tmp/ZscalerRoot0.pem + sudo $0 --use-cert /tmp/ca.pem --all-users + sudo $0 --use-cert /tmp/ca.pem --cert-name zscaler-root +EOF +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this script must be run as root." >&2 + echo "Use: sudo $0 --use-cert [--cert-name ] [--all-users]" >&2 + exit 1 + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-cert) + USE_CERT="${2:?Error: --use-cert requires a value}" + shift 2 + ;; + --cert-name) + CERT_BASENAME="${2:?Error: --cert-name requires a value}" + shift 2 + ;; + --all-users) + ALL_USERS=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$USE_CERT" ]]; then + echo "Error: --use-cert is required." >&2 + usage >&2 + exit 1 + fi + + if [[ ! -f "$USE_CERT" ]]; then + echo "Error: certificate file not found: $USE_CERT" >&2 + exit 1 + fi + + if [[ -z "$CERT_BASENAME" ]]; then + echo "Error: --cert-name cannot be empty." >&2 + exit 1 + fi + + # Reject path-traversal characters so $CERT_BASENAME stays a single segment + # safe to substitute into the JKS alias and the LaunchAgent label suffix. + if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + exit 1 + fi +} + +check_os() { + local os + os="$(uname -s)" + if [[ "$os" != "Darwin" ]]; then + echo "Error: this script supports macOS only (detected: $os)." >&2 + exit 1 + fi +} + +check_dependencies() { + if ! command -v openssl >/dev/null 2>&1; then + echo "Error: openssl is required but not found on PATH." >&2 + exit 1 + fi + # keytool is needed for the JKS step; checked at build_jks entry to keep + # validate_pem reachable even when no JDK is installed yet. +} + +# Locate the JDK's default cacerts file. Mirrors the Linux sibling: see the +# header comment on install_certs_jvm_linux.sh:find_jdk_cacerts for the +# rationale (OpenJDK's -Djavax.net.ssl.trustStore replaces rather than +# extends; we must copy the bundled cacerts as the base of our merged store). +# +# Resolution: $JAVA_HOME first, then dir-of-resolved-keytool. macOS-specific +# wrinkle: BSD readlink doesn't support -f, so we walk symlinks manually +# (Apple's /usr/bin/keytool is a stub that resolves through several layers). +find_jdk_cacerts() { + local candidate="" + + # 1. Explicit JAVA_HOME. Under `sudo` macOS strips this via env_reset, so + # operators who rely on jenv / asdf / SDKMAN must `sudo -E` or `sudo env + # JAVA_HOME=…` — the test runner does this; print a hint if we fall through. + if [[ -n "${JAVA_HOME:-}" && -f "${JAVA_HOME}/lib/security/cacerts" ]]; then + candidate="${JAVA_HOME}/lib/security/cacerts" + fi + + # 2. /usr/libexec/java_home — Apple's canonical resolver, scans + # /Library/Java/JavaVirtualMachines + ~/Library/Java/JavaVirtualMachines + # and prints the highest-version JDK home. Works under sudo with a stripped + # env (no JAVA_HOME) as long as a system-visible JDK is installed. + if [[ -z "$candidate" && -x /usr/libexec/java_home ]]; then + local java_home_out + java_home_out="$(/usr/libexec/java_home 2>/dev/null || true)" + if [[ -n "$java_home_out" && -f "${java_home_out}/lib/security/cacerts" ]]; then + candidate="${java_home_out}/lib/security/cacerts" + fi + fi + + # 3. Sibling of resolved keytool. Defensive last resort — typical macOS + # PATH-keytool is the Apple stub at /usr/bin/keytool (which delegates via + # java_home internally and has no sibling cacerts), but a real JDK on PATH + # (Homebrew openjdk, manually-installed Adoptium) does sit next to a + # cacerts file. BSD readlink lacks -f, so we walk symlinks manually. + if [[ -z "$candidate" ]]; then + local keytool_path resolved link + keytool_path="$(command -v keytool 2>/dev/null || true)" + if [[ -n "$keytool_path" ]]; then + resolved="$keytool_path" + local depth=0 + while [[ -L "$resolved" && $depth -lt 16 ]]; do + link="$(readlink "$resolved")" + if [[ "$link" = /* ]]; then + resolved="$link" + else + resolved="$(dirname "$resolved")/$link" + fi + depth=$((depth + 1)) + done + local keytool_dir + keytool_dir="$(cd "$(dirname "$resolved")" 2>/dev/null && pwd -P)" + if [[ -n "$keytool_dir" && -f "${keytool_dir}/../lib/security/cacerts" ]]; then + candidate="${keytool_dir}/../lib/security/cacerts" + fi + fi + fi + + if [[ -z "$candidate" ]]; then + echo "Error: cannot locate the JDK's default cacerts file." >&2 + echo " Tried (in order): \$JAVA_HOME/lib/security/cacerts," >&2 + echo " /usr/libexec/java_home → */lib/security/cacerts," >&2 + echo " \$(dirname keytool)/../lib/security/cacerts." >&2 + echo " Install a JDK (Homebrew, Adoptium, etc.), or invoke as 'sudo -E ./install_…'" >&2 + echo " to preserve your shell's JAVA_HOME under sudo." >&2 + exit 1 + fi + echo "$candidate" +} + +validate_pem() { + local path="$1" + + # C1 cross-platform parity: require PEM text input. DER would parse via + # openssl + import via keytool here (-inform der would be implied) and + # silently succeed — but the Linux + Windows siblings reject DER, so we + # also reject it for predictable cross-platform behaviour. + if ! grep -q -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null; then + echo "Error: certificate is not PEM-encoded: $path" >&2 + echo " If it's DER, convert first:" >&2 + echo " openssl x509 -inform der -in $path -out $path.pem" >&2 + exit 1 + fi + + if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then + echo "Error: invalid PEM/CRT certificate file: $path" >&2 + exit 1 + fi + + # Reject expired anchors: keytool -importcert -noprompt accepts them silently + # and the user gets cryptic CertificateExpiredException at TLS handshake time. + if ! openssl x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1; then + echo "Error: certificate has already expired: $path" >&2 + exit 1 + fi + + # Warn (don't fail) on a cert expiring within 30 days — likely operator error. + # I23 parity: the 30-day window matches the Linux JVM_LINUX_EXPIRY_WARN_SECONDS + # constant (_jvm_linux_paths.sh) and the Windows AddDays(30) sibling. Change + # all three together — there is no single source of truth across the three. + if ! openssl x509 -in "$path" -checkend 2592000 -noout >/dev/null 2>&1; then + echo "[warn] certificate expires within 30 days: $path" >&2 + fi + + # Reject leaf certs: a cert without CA:TRUE in basicConstraints will import + # into a JKS truststore but PKIX path-building won't use it as a trust anchor. + # + # Stock macOS ships LibreSSL at /usr/bin/openssl, which does NOT support + # `openssl x509 -ext` (that flag is OpenSSL 3.x+). Parse the long-form + # `-text` output instead — works on both LibreSSL and OpenSSL. + local text bc_line ca_value + text="$(openssl x509 -in "$path" -noout -text 2>/dev/null || true)" + bc_line="$(awk ' + /X509v3 Basic Constraints/ { getline; print; exit } + ' <<<"$text")" + if [[ -n "$bc_line" ]]; then + ca_value="$(grep -oE 'CA:(TRUE|FALSE)' <<<"$bc_line" | head -n1 | cut -d: -f2)" + if [[ "$ca_value" == "FALSE" ]]; then + echo "Error: certificate is not a CA (basicConstraints CA:FALSE): $path" >&2 + echo " JKS imports succeed but PKIX rejects non-CA trust anchors." >&2 + exit 1 + fi + fi + + # Warn on bundles: keytool -importcert -noprompt reads only the first cert, + # silently dropping intermediates. Users should split bundles or supply only the root. + local count + count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || echo 0)" + if [[ "$count" -gt 1 ]]; then + echo "[warn] PEM file contains $count certificates; only the first will be imported as the JVM trust anchor." >&2 + echo " Supply only the root CA (or split the bundle) if intermediates are needed." >&2 + fi +} + +require_keytool() { + if ! command -v keytool >/dev/null 2>&1; then + echo "Error: keytool is required (provided by any JDK)." >&2 + echo " Homebrew: brew install openjdk@21" >&2 + echo " Adoptium: https://adoptium.net/temurin/releases/" >&2 + echo " Manual JDK: add \$JAVA_HOME/bin to PATH (or symlink keytool into /usr/local/bin)." >&2 + exit 1 + fi +} + +jks_path_for_user() { + local user_home="$1" + echo "${user_home}/${JKS_RELATIVE_DIR}/${JKS_BASENAME}" +} + +build_jks_for_user() { + local target_user="$1" user_home="$2" + local jks_dir="${user_home}/${JKS_RELATIVE_DIR}" + local jks_path="${jks_dir}/${JKS_BASENAME}" + + local src_cacerts + src_cacerts="$(find_jdk_cacerts)" + echo " [JKS] Building truststore at $jks_path (extending $src_cacerts)" + + # macOS mkdir -p will create the intermediate "Application Support" / + # "JFrog" / "package-route-jvm" tree if missing. Quote the path because + # "Application Support" contains a space. + mkdir -p "$jks_dir" + + # Copy the JDK's bundled cacerts (~150 public root CAs) as the base so the + # merged store keeps trusting Maven Central, Let's Encrypt, etc. Without + # this, -Djavax.net.ssl.trustStore at JTO-resolve time would REPLACE the + # JVM's trust source — a JKS containing only the corporate CA would break + # every public-CA TLS handshake. Idempotent: cp -f overwrites any prior + # JKS, so subsequent installs start from the canonical JDK cacerts again. + cp "$src_cacerts" "$jks_path" + + # Capture keytool's combined output so a real failure (wrong format, + # unreadable cert, keytool-from-broken-JDK, etc.) doesn't leave the user + # with `set -e` aborting at an unhelpful line. The success-case output + # ("Certificate was added to keystore") is informational only. No + # -storetype flag: modern JDKs default cacerts to PKCS12 and keytool + # autodetects the format from the existing file. + local keytool_out + if ! keytool_out="$(keytool -importcert -noprompt \ + -alias "$CERT_BASENAME" \ + -file "$USE_CERT" \ + -keystore "$jks_path" \ + -storepass "$JKS_PASSWORD" 2>&1)"; then + echo "Error: keytool -importcert failed for $jks_path. Output:" >&2 + printf '%s\n' "$keytool_out" | sed 's/^/ /' >&2 + exit 1 + fi + + chmod 0755 "$jks_dir" + chmod 0644 "$jks_path" + + # Hand ownership back to the target user so they (and their LaunchAgent + # running in gui/) can read it without needing sudo to manage it. + # A silent chown failure would leave root-owned files in $target_user's + # home — launchd refuses to load root-owned LaunchAgents, so the install + # would appear to succeed and never activate. + local chown_err + if ! chown_err="$(chown -R "$target_user" "$jks_dir" 2>&1)"; then + echo "Error: chown $target_user $jks_dir failed: $chown_err" >&2 + exit 1 + fi + + echo " [JKS] OK: alias=$CERT_BASENAME" +} + +# Determine the single non-root target user when --all-users is NOT set. +# Fallback order matches install_certs_macos.sh:314-328: SUDO_USER first, +# then /dev/console owner (the JAMF / GUI-elevated case), then `logname` as a +# last resort. `loginwindow` is the special user that owns /dev/console at +# the login screen — must be filtered or we'd install into a non-account. +get_single_target_user() { + local candidate + + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + candidate="$SUDO_USER" + else + candidate="$(stat -f '%Su' /dev/console 2>/dev/null || true)" + if [[ -z "$candidate" || "$candidate" == "root" || "$candidate" == "loginwindow" ]]; then + candidate="$(logname 2>/dev/null || true)" + fi + fi + + if [[ -z "$candidate" || "$candidate" == "root" || "$candidate" == "loginwindow" ]]; then + return 0 + fi + + # Reject users that don't exist on the box. + if ! id -u "$candidate" >/dev/null 2>&1; then + return 0 + fi + + echo "$candidate" +} + +get_user_home() { + local user="$1" + # `dscl . -read /Users/$user NFSHomeDirectory` is the macOS canonical + # source of truth (passwd is a synthetic view). Fall back to dscacheutil + # if dscl is unavailable (sandboxed CI images, OpenDirectory hiccups). + # Avoid `eval echo "~$user"` — even with the upstream id-validation it + # makes the next maintainer's eyes water and would be unsafe if the + # validation is ever loosened. + local home + home="$(dscl . -read "/Users/${user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" + if [[ -z "$home" ]] && command -v dscacheutil >/dev/null 2>&1; then + home="$(dscacheutil -q user -a name "$user" 2>/dev/null | awk '/^dir:/ {print $2}')" + fi + echo "$home" +} + +launch_agent_path_for_user() { + local user_home="$1" + echo "${user_home}/${LAUNCH_AGENT_RELATIVE_DIR}/${LAUNCH_AGENT_BASENAME}" +} + +# XML-escape a single value to make it safe inside a node. +# JKS paths contain spaces ("Application Support") but spaces in XML text are +# fine; the only chars that must be escaped are &, <, >. +plist_xml_escape() { + local s="$1" + s="${s//&/&}" + s="${s///>}" + # `"` inside PCDATA is technically valid XML, but we escape it + # for defence-in-depth: the JTO value embeds literal `"` chars to quote + # the trustStore path against JVM whitespace tokenisation, and plutil + # versions on older macOS releases have been finicky about unescaped + # quotes inside . Escaping is safe across plist consumers. + s="${s//\"/"}" + printf '%s' "$s" +} + +write_launch_agent_plist() { + local target_user="$1" user_home="$2" + local plist_path + plist_path="$(launch_agent_path_for_user "$user_home")" + local plist_dir + plist_dir="$(dirname "$plist_path")" + + local jks_path + jks_path="$(jks_path_for_user "$user_home")" + + # The JKS path is under ~/Library/Application Support/ — the embedded + # space breaks unquoted JAVA_TOOL_OPTIONS at the JVM tokenizer (which + # splits on whitespace and only honours `"…"` grouping). Without these + # inner quotes a Dock-launched JVM sees two tokens, the second of which + # starts with `Support/…` and aborts with "Unrecognized option". The + # plist carries the value verbatim through launchctl setenv, + # so the literal quote characters reach the JVM tokenizer and correctly + # group the path. plist_xml_escape converts `"` → `"` for the XML. + local jto_value="-Djavax.net.ssl.trustStore=\"${jks_path}\" -Djavax.net.ssl.trustStorePassword=\"${JKS_PASSWORD}\"" + local jto_escaped + jto_escaped="$(plist_xml_escape "$jto_value")" + + local uid + uid="$(id -u "$target_user")" + local log_out="/tmp/${LAUNCH_AGENT_LABEL}-${uid}.out.log" + local log_err="/tmp/${LAUNCH_AGENT_LABEL}-${uid}.err.log" + + echo " [Agent] Writing plist: $plist_path" + + mkdir -p "$plist_dir" + + # Atomic write via mktemp + mv in the same directory so a half-written + # plist can never be picked up by launchd. + local tmp + tmp="$(mktemp "${plist_dir}/.${LAUNCH_AGENT_BASENAME}.XXXXXX")" + cat > "$tmp" < + + + + Label + ${LAUNCH_AGENT_LABEL} + ProgramArguments + + /bin/launchctl + setenv + JAVA_TOOL_OPTIONS + ${jto_escaped} + + RunAtLoad + + StandardOutPath + ${log_out} + StandardErrorPath + ${log_err} + + +PLIST + + # plutil -lint catches XML / schema errors before launchd ever sees the file. + if ! plutil -lint "$tmp" >/dev/null 2>&1; then + local lint_err + lint_err="$(plutil -lint "$tmp" 2>&1 || true)" + rm -f "$tmp" + echo "Error: generated LaunchAgent plist failed plutil -lint: $lint_err" >&2 + exit 1 + fi + + mv "$tmp" "$plist_path" + chmod 0644 "$plist_path" + + # launchd refuses to load LaunchAgents owned by root in ~/Library/LaunchAgents/, + # so a silent chown failure here turns the install into a phantom success. + local chown_err + if ! chown_err="$(chown "$target_user" "$plist_dir" "$plist_path" 2>&1)"; then + echo "Error: chown $target_user $plist_path failed: $chown_err" >&2 + exit 1 + fi + + echo " [Agent] OK" +} + +bootstrap_launch_agent() { + local target_user="$1" user_home="$2" + local plist_path + plist_path="$(launch_agent_path_for_user "$user_home")" + local uid + uid="$(id -u "$target_user")" + local domain="gui/${uid}" + + # `gui/` exists only while the user is logged into a GUI session. + # Under --all-users this is normally false for everyone except the + # currently-logged-in account. The plist we already wrote into + # ~/Library/LaunchAgents/ is loaded automatically by launchd when the + # user next logs in (RunAtLoad=true), so a missing domain is a soft-fail. + if ! launchctl print "$domain" >/dev/null 2>&1; then + echo " [Agent] $target_user is not in an active GUI session; skipping bootstrap." + echo " [Agent] Plist is installed at $plist_path and will activate at next login." + return 0 + fi + + echo " [Agent] launchctl bootout (in case already loaded; expected to fail on a fresh install)" + # `launchctl bootout` returns non-zero when the agent isn't loaded yet. + # That's the normal first-run case; swallow it. + launchctl bootout "$domain" "$plist_path" 2>/dev/null || true + + echo " [Agent] launchctl bootstrap $domain" + local bootstrap_err + if ! bootstrap_err="$(launchctl bootstrap "$domain" "$plist_path" 2>&1)"; then + echo " [Agent] [warn] launchctl bootstrap failed for $domain: $bootstrap_err" >&2 + echo " Plist is installed at $plist_path and will activate at next login." >&2 + return 0 + fi + + # Sanity-check that setenv actually fired. `launchctl bootstrap` blocks + # until the agent is *loaded* but the ProgramArguments exec + # (`launchctl setenv …`) runs asynchronously after load. Under a quiet + # box this is microseconds; under EDR agents (CrowdStrike, Spotlight + # mid-index, Time Machine) the exec latency can climb past a second. + # Retry up to ~2s (20 × 100ms) so a perfectly-healthy install doesn't + # produce a misleading warn under load. + local seen attempt + for attempt in $(seq 1 20); do + seen="$(launchctl asuser "$uid" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" + [[ -n "$seen" ]] && break + sleep 0.1 + done + + if [[ -z "$seen" ]]; then + # The env var IS set in the plist on disk and the agent IS loaded. + # We just couldn't observe the launchctl-setenv side-effect inside + # our retry window. Surface that without suggesting a logout/login + # dance the user almost certainly doesn't need. + echo " [Agent] [warn] JAVA_TOOL_OPTIONS not observable via launchctl getenv within 2s." >&2 + echo " Plist is loaded; re-run validate_certs_jvm_macos.sh in a few seconds" >&2 + echo " to confirm, or open a new Terminal and check \$JAVA_TOOL_OPTIONS." >&2 + else + echo " [Agent] launchctl getenv JAVA_TOOL_OPTIONS confirms the value reached gui/${uid}" + fi +} + +install_for_user() { + local target_user="$1" user_home="$2" + + echo "=== User: $target_user (home: $user_home) ===" + build_jks_for_user "$target_user" "$user_home" + write_launch_agent_plist "$target_user" "$user_home" + bootstrap_launch_agent "$target_user" "$user_home" + + echo " Truststore: $(jks_path_for_user "$user_home")" + echo " LaunchAgent: $(launch_agent_path_for_user "$user_home")" +} + +# Outputs `username\thome_dir` lines for every /Users/* directory that +# represents a real account with UID >= 501. +# +# Stricter filter than install_certs_macos.sh:256-261 (which skips only +# /Users/Shared and UID < 501): also skips .localized, and rejects +# directories whose owning user no longer exists in dscl (stale home dirs +# left behind by deleted accounts would otherwise crash chown/launchctl). +iter_all_users() { + local dir base uid + for dir in /Users/*; do + [[ -d "$dir" ]] || continue + base="$(basename "$dir")" + [[ "$base" == "Shared" || "$base" == ".localized" ]] && continue + uid="$(stat -f '%u' "$dir" 2>/dev/null || true)" + [[ -n "$uid" && "$uid" -ge 501 ]] || continue + # Reject stale home dirs whose owning user no longer exists in dscl. + id -u "$base" >/dev/null 2>&1 || continue + printf '%s\t%s\n' "$base" "$dir" + done +} + +print_caveats() { + cat < per installed user. New launchd-spawned + processes (Dock-launched IntelliJ, JetBrains Toolbox, 'open -a …') inherit + JAVA_TOOL_OPTIONS automatically. + - Apps that were ALREADY running before the install must be restarted to pick + up the new env var (LaunchServices does not re-poll the launchd domain on + Cmd-Tab). + - Run 'gradle --stop' to refresh the Gradle Daemon if one was already running. + - The 'Picked up JAVA_TOOL_OPTIONS:' banner on stderr is expected. +EOF +} + +main() { + require_root + parse_args "$@" + check_os + check_dependencies + validate_pem "$USE_CERT" + require_keytool + + if [[ "$ALL_USERS" -eq 1 ]]; then + local iter_count=0 user home + while IFS=$'\t' read -r user home; do + echo + install_for_user "$user" "$home" + iter_count=$((iter_count + 1)) + done < <(iter_all_users) + + if [[ "$iter_count" -eq 0 ]]; then + echo "Error: --all-users found no eligible accounts under /Users/* (UID >= 501)." >&2 + exit 1 + fi + + echo + echo "Installed for $iter_count user(s)." + print_caveats + return 0 + fi + + local target_user user_home + target_user="$(get_single_target_user)" + if [[ -z "$target_user" ]]; then + echo "Error: could not determine non-root target user." >&2 + echo " Set SUDO_USER or invoke via sudo from the developer account," >&2 + echo " or pass --all-users to iterate every eligible account." >&2 + exit 1 + fi + user_home="$(get_user_home "$target_user")" + if [[ -z "$user_home" || ! -d "$user_home" ]]; then + echo "Error: home directory not found for $target_user." >&2 + exit 1 + fi + + echo + install_for_user "$target_user" "$user_home" + print_caveats +} + +main "$@" diff --git a/testing/test_install_certs_jvm_macos.sh b/testing/test_install_certs_jvm_macos.sh new file mode 100755 index 0000000..168991b --- /dev/null +++ b/testing/test_install_certs_jvm_macos.sh @@ -0,0 +1,430 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Smoke matrix for install_certs_jvm_macos.sh + validate_certs_jvm_macos.sh. +# +# Run as root from the repo root (matches macos-latest CI usage): +# sudo ./testing/test_install_certs_jvm_macos.sh +# +# Targets the SUDO_USER's per-user files. `cleanup` runs at the start of +# cases 1, 4, 10, 11 (the positive / fresh-state cases) and via `trap EXIT`. +# Cases 2 (subject mismatch), 3 (idempotency), 5-8 (negative arg / cert), +# 9 (getenv) and 12 (validate_pem warns) deliberately reuse the prior install +# state because they assert behavior ON TOP of an installed system. +# +# Invariants exercised: +# 1. Positive install + validate (subject substring match) +# 2. Subject mismatch -> exit 1 +# 3. Idempotent re-install (single JKS alias after 2 runs; plist replaced) +# 4. Custom --cert-name round-trips (alias inside JKS = cert-name) +# 5. Path-traversal --cert-name rejected +# 6. Malformed PEM rejected +# 7. Expired CA rejected (skip if openssl can't produce one verifiably-expired) +# 8. Leaf cert (CA:FALSE) rejected +# 9. After bootstrap, launchctl getenv JAVA_TOOL_OPTIONS in gui/ returns +# the JKS path (skip on CI runners with no GUI session) +# 10. Plist content is well-formed XML and points at the expected JKS path +# (covers the install path even when launchctl can't be verified) +# 11. --all-users iterates /Users/* and installs into every eligible account +# (covers the iter_all_users filter + per-user chown contract) +# 12. validate_pem warn paths: 30-day-expiry warn + multi-cert bundle warn + +set -euo pipefail +fail_msg() { echo "BUG: $1" >&2; exit 1; } + +if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this test runner must be run as root. Use: sudo $0" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# Identify the test user. CI calls `sudo ./test_install_certs_jvm_macos.sh` +# from an interactive account, so SUDO_USER is set; locally same. Fall back +# to the GUI console user for JAMF-style flows. +if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + TEST_USER="$SUDO_USER" +else + TEST_USER="$(stat -f '%Su' /dev/console 2>/dev/null || true)" +fi +if [[ -z "$TEST_USER" || "$TEST_USER" == "root" ]]; then + fail_msg "cannot determine non-root test user (no SUDO_USER, no console user)" +fi + +TEST_HOME="$(dscl . -read "/Users/${TEST_USER}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" +[[ -d "$TEST_HOME" ]] || fail_msg "home directory not found for $TEST_USER" +TEST_UID="$(id -u "$TEST_USER")" + +JKS="${TEST_HOME}/Library/Application Support/JFrog/package-route-jvm/truststore.jks" +JKS_DIR="${TEST_HOME}/Library/Application Support/JFrog/package-route-jvm" +PLIST="${TEST_HOME}/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist" +LABEL="com.jfrog.package-reroute.jto-env" + +echo "Test user: $TEST_USER (uid=$TEST_UID, home=$TEST_HOME)" + +cleanup() { + launchctl bootout "gui/${TEST_UID}/${LABEL}" 2>/dev/null || true + rm -f "$PLIST" + rm -rf "$JKS_DIR" + launchctl asuser "${TEST_UID}" launchctl unsetenv JAVA_TOOL_OPTIONS 2>/dev/null || true +} +trap cleanup EXIT + +# Pick an OpenSSL implementation that supports `-addext` reliably. +# macos-latest CI's default `openssl` is LibreSSL, which silently mis-handles +# -addext and emits PEM bytes keytool then rejects with "Input not an X.509 +# certificate". Homebrew's openssl@3 is preinstalled on GHA macos-latest. +OPENSSL="" +for cand in /opt/homebrew/opt/openssl@3/bin/openssl /usr/local/opt/openssl@3/bin/openssl openssl; do + if command -v "$cand" >/dev/null 2>&1 && "$cand" version 2>/dev/null | grep -q '^OpenSSL '; then + OPENSSL="$cand" + break + fi +done +[[ -n "$OPENSSL" ]] || fail_msg "no real OpenSSL on PATH (need OpenSSL 3.x; LibreSSL does not support -addext)" +echo "Using openssl: $OPENSSL ($("$OPENSSL" version))" + +# Generate the lab CA used by all positive cases. +"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/jvm-mac-test-k.pem -out /tmp/jvm-mac-test-ca.pem -days 7 \ + -subj "/CN=Lab JVM mac CA Test/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null + +# Capture combined stdout/stderr to a tempfile and only dump it on an +# *unexpected* exit. Negative tests need silence on the expected-fail path +# but diagnostic output when the installer surprises us. Keeps CI logs +# tight in the green-run case (the iteration-1 debug cycle showed how +# painful the silent-on-failure pattern is). +install_as_test_user() { + # The `if cmd; then …; fi` pattern reports $?=0 of the if-statement, not + # the command. `if ! cmd` also doesn't help (the `!` operator itself + # returns 0). Reliable capture: pre-set rc=0 and use `cmd || rc=$?`. + local log rc=0 + log="$(mktemp)" + SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh "$@" >"$log" 2>&1 || rc=$? + if [[ "$rc" -eq 0 ]]; then + rm -f "$log" + else + _LAST_LOG="$log" + fi + return "$rc" +} + +validate_as_test_user() { + local log rc=0 + log="$(mktemp)" + SUDO_USER="$TEST_USER" ./validate_certs_jvm_macos.sh "$@" >"$log" 2>&1 || rc=$? + if [[ "$rc" -eq 0 ]]; then + rm -f "$log" + else + _LAST_LOG="$log" + fi + return "$rc" +} + +dump_last_log() { + [[ -n "${_LAST_LOG:-}" && -f "$_LAST_LOG" ]] || return 0 + echo "--- captured output ---" + cat "$_LAST_LOG" + echo "--- end captured output ---" + rm -f "$_LAST_LOG" + unset _LAST_LOG +} + +#----------------------------------------------------------------------------- +echo +echo "=== 1. positive: install + validate ===" +cleanup +# Positive cases let stdout through so a CI failure shows a useful log; only +# negative cases (where we *expect* exit 1) silence both streams. +SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem +SUDO_USER="$TEST_USER" ./validate_certs_jvm_macos.sh --expected-subject "Lab JVM mac CA Test" +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 2. negative: subject mismatch must exit 1 ===" +if validate_as_test_user --expected-subject "Microsoft Root CA NoMatch"; then + fail_msg "validator should have exited 1 on subject mismatch" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 3. idempotency: 2nd install produces single alias / single plist ===" +install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem +validate_as_test_user --expected-subject "Lab JVM mac CA Test" + +alias_count=$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -c "trustedCertEntry" || true) +alias_count=${alias_count:-0} +# JKS extends the JDK's bundled cacerts (~150 public roots) plus exactly one +# corporate-CA alias. After two installs, the corporate alias count must be +# exactly 1; the JDK-supplied aliases stay constant. +corp_count=$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -cE "^${CERT_BASENAME:-package-route-custom-ca}[,[:space:]]" || true) +corp_count=${corp_count:-0} +[[ "$corp_count" -eq 1 ]] || fail_msg "expected exactly 1 corporate-CA alias after 2 installs, got $corp_count" +[[ "$alias_count" -ge 100 ]] || fail_msg "expected JKS to extend default cacerts (>=100 aliases), got $alias_count" +[[ -f "$PLIST" ]] || fail_msg "plist missing after 2nd install" +echo " ok (alias_count=$alias_count)" + +#----------------------------------------------------------------------------- +echo +echo "=== 4. custom --cert-name round-trips (alias inside JKS = cert-name) ===" +cleanup +install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem --cert-name zscaler-root +keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -q "^zscaler-root," \ + || fail_msg "expected JKS alias 'zscaler-root' (got: $(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null | grep trustedCertEntry))" +validate_as_test_user --expected-subject "Lab JVM mac CA Test" +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 5. negative: path-traversal --cert-name rejected ===" +if install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem --cert-name '../etc/pwned'; then + dump_last_log + fail_msg "installer should have rejected path-traversal --cert-name" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 6. negative: malformed PEM rejected ===" +echo "not a certificate" > /tmp/jvm-mac-bad.pem +if install_as_test_user --use-cert /tmp/jvm-mac-bad.pem; then + dump_last_log + fail_msg "installer should have rejected malformed PEM" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 7. negative: expired CA rejected ===" +rm -f /tmp/jvm-mac-expired.pem +# Try OpenSSL 3.2+'s `-not_before / -not_after`; fall back to negative `-days`. +"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/jvm-mac-expired-k.pem -out /tmp/jvm-mac-expired.pem \ + -subj "/CN=Expired/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -not_before 20200101000000Z -not_after 20200201000000Z 2>/dev/null \ +|| "$OPENSSL" x509 -req -in <("$OPENSSL" req -new -key /tmp/jvm-mac-test-k.pem -subj "/CN=Expired") \ + -signkey /tmp/jvm-mac-test-k.pem -days -1 -out /tmp/jvm-mac-expired.pem 2>/dev/null \ +|| true + +if [[ ! -f /tmp/jvm-mac-expired.pem ]] || "$OPENSSL" x509 -in /tmp/jvm-mac-expired.pem -checkend 0 -noout >/dev/null 2>&1; then + echo " SKIP: cannot produce a verifiably expired cert with the installed openssl ($(openssl version))" +else + if install_as_test_user --use-cert /tmp/jvm-mac-expired.pem; then + dump_last_log + fail_msg "installer should have rejected expired CA" + fi + echo " ok" +fi + +#----------------------------------------------------------------------------- +echo +echo "=== 8. negative: leaf cert (CA:FALSE) rejected ===" +"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/jvm-mac-leaf-k.pem -out /tmp/jvm-mac-leaf.pem -days 7 \ + -subj "/CN=Leaf Not CA" \ + -addext "basicConstraints=critical,CA:FALSE" 2>/dev/null +if install_as_test_user --use-cert /tmp/jvm-mac-leaf.pem; then + dump_last_log + fail_msg "installer should have rejected leaf cert (CA:FALSE)" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 9. launchctl getenv JAVA_TOOL_OPTIONS in gui/ ===" +cleanup +install_as_test_user --use-cert /tmp/jvm-mac-test-ca.pem +if launchctl print "gui/${TEST_UID}" >/dev/null 2>&1; then + # Mirror the installer's 20x100ms retry: bootstrap returns the moment + # the agent is loaded, but the launchctl-setenv ProgramArguments runs + # asynchronously. Without a retry the assertion would flake on GUI + # runners under load. + jto="" + # 20 retries × 100ms = ~2s total — mirrors installer's bootstrap_launch_agent + # to cover the async-setenv window even under EDR load. + # shellcheck disable=SC2034 + for _i in $(seq 1 20); do + jto="$(launchctl asuser "${TEST_UID}" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" + [[ -n "$jto" ]] && break + sleep 0.1 + done + # Accept both quoted and unquoted forms. The post-fix installer writes + # the quoted form so JVM tokenization handles the "Application Support" + # space; older installs left it unquoted. + case "$jto" in + *"trustStore=\"${JKS}\""*|*"trustStore=${JKS}"*) echo " ok" ;; + *) fail_msg "launchctl getenv mismatch (got: $jto)" ;; + esac +else + echo " SKIP: gui/${TEST_UID} not active (CI runner with no GUI session is fine — plist will load at login)" +fi + +#----------------------------------------------------------------------------- +echo +echo "=== 10. plist is well-formed XML and points at the expected JKS ===" +# Reuses the install from step 9 (no cleanup). Independent of whether +# launchctl bootstrap succeeded — validates the write_launch_agent_plist +# code path even on headless CI runners. +plutil -lint "$PLIST" >/dev/null +# Confirm the JTO value inside the plist actually references the expected +# JKS path. plutil -extract pulls the 4th element of ProgramArguments +# (index 3) which is the `-Djavax.net.ssl.trustStore=… …` arg string. +jto_in_plist="$(plutil -extract ProgramArguments.3 raw "$PLIST" 2>/dev/null)" +case "$jto_in_plist" in + *"trustStore=\"${JKS}\""*|*"trustStore=${JKS}"*) echo " ok" ;; + *) fail_msg "plist JTO arg doesn't point at $JKS (got: $jto_in_plist)" ;; +esac + +#----------------------------------------------------------------------------- +echo +echo "=== 11. --all-users iterates eligible accounts ===" +cleanup +# CI runners only have a single user (`runner`, uid 501). That's enough to +# verify iter_all_users does at least one iteration through the filter + +# per-user-chown path; multi-user is covered by the local dev Mac smoke. +# Run without SUDO_USER set so the installer takes the --all-users branch. +out="$(./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem --all-users 2>&1)" +echo "$out" | grep -q "=== User: ${TEST_USER}" \ + || { echo "$out" | tail -20; fail_msg "--all-users did not iterate ${TEST_USER}"; } +echo "$out" | grep -qE "Installed for [0-9]+ user\(s\)" \ + || { echo "$out" | tail -20; fail_msg "--all-users summary line missing"; } +# Per-user files should be owned by the target user (not root). +plist_owner="$(stat -f '%Su' "$PLIST")" +[[ "$plist_owner" == "$TEST_USER" ]] \ + || fail_msg "plist owner=$plist_owner, expected $TEST_USER (chown failed silently?)" +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 12. validate_pem warn paths: 30-day-expiry + bundle ===" +# Short-validity CA (1 day) — within 30 days, should produce the expiry warn +# but install succeed. +"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/jvm-mac-soon-k.pem -out /tmp/jvm-mac-soon.pem -days 1 \ + -subj "/CN=Soon to Expire/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null +out="$(SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-soon.pem 2>&1)" +echo "$out" | grep -q "certificate expires within 30 days" \ + || { echo "$out" | tail -20; fail_msg "30-day expiry warn missing"; } + +# Multi-cert bundle: append a second cert to the test CA. The installer +# warns and imports only the first; install must still succeed. +cat /tmp/jvm-mac-test-ca.pem /tmp/jvm-mac-soon.pem > /tmp/jvm-mac-bundle.pem +out="$(SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-bundle.pem 2>&1)" +echo "$out" | grep -qE "PEM file contains [0-9]+ certificates" \ + || { echo "$out" | tail -20; fail_msg "multi-cert bundle warn missing"; } +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 13. negative: missing keytool fails cleanly ===" +# I3 cross-platform parity: build an isolated bin/ containing symlinks to +# every /usr/bin and /bin tool EXCEPT keytool, plus openssl from its real +# location. Then run the installer with PATH=. macOS-latest +# CI has /usr/bin/keytool from the Apple-bundled JavaAppletPlugin, and +# setup-java prepends $JAVA_HOME/bin — neither survives this isolated PATH. +nokey_bin="$(mktemp -d)" +for d in /usr/bin /bin /usr/sbin /sbin; do + [[ -d "$d" ]] || continue + for f in "$d"/*; do + base="$(basename "$f")" + [[ "$base" == keytool ]] && continue + ln -sf "$f" "$nokey_bin/$base" 2>/dev/null || true + done +done +# openssl may live outside /usr/bin (Homebrew on macOS GHA puts it in +# /opt/homebrew/bin). Ensure the isolated bin can find a working openssl. +ln -sf "$OPENSSL" "$nokey_bin/openssl" 2>/dev/null || true +if ! "$nokey_bin/openssl" version >/dev/null 2>&1; then + rm -rf "$nokey_bin" + fail_msg "isolated bin missing openssl — cannot exercise missing-keytool path" +fi +if [[ -e "$nokey_bin/keytool" ]] || env -i PATH="$nokey_bin" command -v keytool >/dev/null 2>&1; then + rm -rf "$nokey_bin" + fail_msg "isolated bin still has keytool — test setup broken" +fi +if env -i PATH="$nokey_bin" HOME="$HOME" SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem >/tmp/jvm-mac-nokey.out 2>&1; then + cat /tmp/jvm-mac-nokey.out | head -20 + rm -rf "$nokey_bin" + fail_msg "installer should have rejected missing keytool" +fi +if ! grep -q -i keytool /tmp/jvm-mac-nokey.out; then + cat /tmp/jvm-mac-nokey.out | head -10 + rm -rf "$nokey_bin" + fail_msg "missing-keytool error message should mention 'keytool'" +fi +rm -rf "$nokey_bin" +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 14. negative: DER cert rejected (C1 cross-platform parity) ===" +# C1 backport: convert the lab CA to DER, then attempt install — should +# fail with a hint to convert back. Mirrors Linux + Windows behavior. +"$OPENSSL" x509 -in /tmp/jvm-mac-test-ca.pem -outform DER -out /tmp/jvm-mac-test-ca.der 2>/dev/null +if SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.der >/tmp/jvm-mac-der.out 2>&1; then + cat /tmp/jvm-mac-der.out | head -10 + fail_msg "installer should have rejected DER-encoded cert" +fi +grep -q -i "PEM-encoded" /tmp/jvm-mac-der.out \ + || { cat /tmp/jvm-mac-der.out | head -10; fail_msg "DER reject message should mention 'PEM-encoded'"; } +echo " ok" + +#----------------------------------------------------------------------------- +# Re-install once so the next three invariants observe the post-fix end state. +cleanup +SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-cert /tmp/jvm-mac-test-ca.pem >/dev/null + +echo +echo "=== 15. JKS extends default cacerts (preserves public roots) ===" +# Regression guard for the "trustStore replaces, not extends" footgun. +# -Djavax.net.ssl.trustStore in OpenJDK swaps the JVM's trust source; a JKS +# holding only the corporate CA would break every public-CA TLS handshake +# (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). +# Installer must therefore cp $JAVA_HOME/lib/security/cacerts first. +alias_count="$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true)" +alias_count="${alias_count:-0}" +[[ "$alias_count" -ge 100 ]] \ + || fail_msg "JKS has $alias_count aliases; expected >= 100 (JDK cacerts ~150 public roots + corporate CA)" +echo " ok ($alias_count aliases)" + +echo +echo "=== 16. JKS contains a well-known public root (DigiCert family) ===" +# Spot-check the merge actually happened. DigiCert root certs ship in every +# JDK's cacerts under several aliases (digicertglobalrootca, digicertglobalrootg2, +# digicerttrustedrootg4, etc.) — case-insensitive substring match catches them all. +keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -qi 'digicert' \ + || fail_msg "JKS missing the DigiCert family of public roots; the copy-from-JDK step did not run" +echo " ok" + +echo +echo "=== 17. JAVA_TOOL_OPTIONS round-trips through JVM tokenizer ===" +# Direct repro of the "Application Support" space-tokenisation bug. Spawn a +# child java -version with the LaunchAgent's env var and assert the JVM +# does NOT print "Unrecognized option" — that's what an unquoted trustStore +# path produced before the fix. +jto_seen="$(launchctl asuser "$TEST_UID" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" +if [[ -z "$jto_seen" ]]; then + echo " SKIP: gui/$TEST_UID is not active (no logged-in GUI session) — cannot exercise the tokenizer round-trip" +else + java_out="$(JAVA_TOOL_OPTIONS="$jto_seen" java -version 2>&1 || true)" + if grep -q 'Unrecognized option' <<<"$java_out"; then + printf '%s\n' "$java_out" | head -10 + echo "JTO seen: $jto_seen" + fail_msg "java -version reported 'Unrecognized option' — JAVA_TOOL_OPTIONS tokenization is broken (likely missing inner quotes around the JKS path)" + fi + echo " ok (java -version accepted JTO=$jto_seen)" +fi + +echo +echo "=================================================================" +echo "ALL SMOKE TESTS PASSED" +echo "=================================================================" diff --git a/validate_certs_jvm_macos.sh b/validate_certs_jvm_macos.sh new file mode 100755 index 0000000..e6fa9a5 --- /dev/null +++ b/validate_certs_jvm_macos.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Validate JVM truststore installation done by install_certs_jvm_macos.sh. +# +# Asserts, per user: +# 1. JKS file exists at ~/Library/Application Support/JFrog/package-route-jvm/truststore.jks +# 2. JKS contains a cert whose subject matches --expected-subject +# 3. LaunchAgent plist exists at ~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist +# 4. launchctl getenv JAVA_TOOL_OPTIONS in gui/ returns the JKS path +# (only verifiable when the user is in an active GUI session; warn-not-fail +# otherwise — the plist still loads on next login). +# +# Run: +# bash validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" +# sudo bash validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" --all-users +# +# Exit 0 = all checks passed; 1 = at least one failure. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# validate_certs_jvm_linux.sh — system anchor OR JKS+JTO check +# validate_certs_jvm_windows.ps1 — HKCU\Environment JTO check +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ ! -f "${SCRIPT_DIR}/_jvm_macos_paths.sh" ]]; then + echo "Error: _jvm_macos_paths.sh not found next to this validator (${SCRIPT_DIR})." >&2 + echo " Invoke as ./validate_certs_jvm_macos.sh — not via 'curl | bash'." >&2 + exit 1 +fi +# shellcheck disable=SC1091 +. "${SCRIPT_DIR}/_jvm_macos_paths.sh" + +ALL_USERS=0 +EXPECTED_SUBJECT="" + +usage() { + cat < [--all-users] [--cert-name ] + +Options: + --expected-subject Required. Case-insensitive substring match against the cert subject. + --all-users Iterate /Users/* (UID >= 501). Requires root. + --cert-name Accepted for cross-platform CLI parity with the Linux validator. + Ignored here: macOS matches by subject substring, and the JKS path / + LaunchAgent label are fixed per-user regardless of cert-name. + -h, --help Show this help + +Exits 0 if all checks pass, 1 if any check fails. Result line is qualified +with a count of any non-fatal warnings (e.g. gui/ domain absent on +headless / non-active accounts). +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --all-users) + ALL_USERS=1 + shift + ;; + --expected-subject) + EXPECTED_SUBJECT="${2:?Error: --expected-subject requires a value}" + shift 2 + ;; + --cert-name) + # Cross-platform CLI parity (see usage). A fleet wrapper that + # passes --cert-name to all three validators must not fail on + # macOS. We accept and silently ignore: macOS matches by + # subject substring, not by alias name. + : "${2:?Error: --cert-name requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$EXPECTED_SUBJECT" ]]; then + echo "Error: --expected-subject is required." >&2 + usage >&2 + exit 1 + fi + + if [[ "$ALL_USERS" -eq 1 && "$(id -u)" -ne 0 ]]; then + echo "Error: --all-users requires root (other users' ~/Library is 0700)." >&2 + echo "Use: sudo $0 --all-users --expected-subject ..." >&2 + exit 1 + fi +} + +check_os() { + local os + os="$(uname -s)" + if [[ "$os" != "Darwin" ]]; then + echo "Error: this script supports macOS only (detected: $os)." >&2 + exit 1 + fi +} + +FAIL=0 +WARN=0 +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } +ok() { echo " OK: $1"; } +warn() { echo " WARN: $1"; WARN=$((WARN + 1)); } + +# When iterating --all-users, reads of /Users//Library need root. +# Wrap file tests so the same call works in both single-user and --all-users mode. +file_exists() { + local path="$1" + if [[ -f "$path" ]]; then + return 0 + fi + [[ "$ALL_USERS" -eq 1 ]] && sudo test -f "$path" +} + +get_user_home() { + local user="$1" + local home + home="$(dscl . -read "/Users/${user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" + if [[ -z "$home" ]]; then + home="$(eval echo "~${user}")" + fi + echo "$home" +} + +iter_all_users() { + local dir base uid + for dir in /Users/*; do + [[ -d "$dir" ]] || continue + base="$(basename "$dir")" + [[ "$base" == "Shared" || "$base" == ".localized" ]] && continue + uid="$(stat -f '%u' "$dir" 2>/dev/null || true)" + [[ -n "$uid" && "$uid" -ge 501 ]] || continue + id -u "$base" >/dev/null 2>&1 || continue + printf '%s\t%s\n' "$base" "$dir" + done +} + +validate_keystore_contains_subject() { + local keystore="$1" storepass="$2" label="$3" + if ! file_exists "$keystore"; then + fail "$label keystore does not exist: $keystore" + return 1 + fi + if ! command -v keytool >/dev/null 2>&1; then + # JKS exists but we can't open it. Don't silently pass the core invariant. + fail "$label keystore present but keytool is not on PATH; cannot verify subject. Install a JDK." + return 1 + fi + + # Capture keytool output explicitly. Reading another user's keystore under + # --all-users requires sudo; the single-user case reads as the current user. + local keytool_output rc + if [[ "$ALL_USERS" -eq 1 ]]; then + keytool_output="$(sudo keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)" + rc=$? + else + keytool_output="$(keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)" + rc=$? + fi + if [[ "$rc" -ne 0 ]]; then + fail "$label: keytool could not read the keystore. Output (first 3 lines):" + printf '%s\n' "$keytool_output" | head -n3 | sed 's/^/ /' + return 1 + fi + + # Two-stage filter via a variable, not a pipe-pair: under set -o pipefail + # the second grep -qi exits early on match and SIGPIPEs the first, which + # poisons the pipeline status and turns positive matches into false negatives. + local owners + owners="$(printf '%s\n' "$keytool_output" | grep -E '^Owner:' || true)" + if ! grep -qi "$EXPECTED_SUBJECT" <<<"$owners"; then + fail "$label has no cert with subject matching: $EXPECTED_SUBJECT" + return 1 + fi + # I8 cross-platform parity (see validate_certs_jvm_linux.sh): refuse to + # validate stores that contain key material. The installer only writes + # trustedCertEntry records, so any PrivateKeyEntry here indicates drift + # — likely a future installer change or hand-edited store. The well-known + # `changeit` password is unsuitable for actual private-key protection. + if printf '%s\n' "$keytool_output" | grep -qE '^Entry type: PrivateKeyEntry'; then + fail "$label contains a PrivateKeyEntry — this truststore must hold only trustedCertEntry records." + return 1 + fi + ok "$label contains cert with subject matching: $EXPECTED_SUBJECT" + return 0 +} + +validate_launch_agent_plist() { + local plist_path="$1" label="$2" + if file_exists "$plist_path"; then + if command -v plutil >/dev/null 2>&1; then + if [[ "$ALL_USERS" -eq 1 ]]; then + if sudo plutil -lint "$plist_path" >/dev/null 2>&1; then + ok "$label plist exists and is well-formed: $plist_path" + return 0 + fi + else + if plutil -lint "$plist_path" >/dev/null 2>&1; then + ok "$label plist exists and is well-formed: $plist_path" + return 0 + fi + fi + fail "$label plist exists but plutil -lint reports it as malformed: $plist_path" + return 1 + fi + ok "$label plist exists: $plist_path" + return 0 + fi + fail "$label LaunchAgent plist not found: $plist_path" + return 1 +} + +validate_launchctl_getenv() { + local target_uid="$1" jks_path="$2" label="$3" + local domain="gui/${target_uid}" + + if ! launchctl print "$domain" >/dev/null 2>&1; then + warn "$label is not in an active GUI session (no $domain); plist will activate at next login." + return 0 + fi + + local seen + seen="$(launchctl asuser "$target_uid" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" + if [[ -z "$seen" ]]; then + fail "$label: $domain is active but launchctl getenv JAVA_TOOL_OPTIONS is empty." + return 1 + fi + + # Accept both quoted and unquoted forms. The installer writes the quoted + # form so the JVM tokenizer respects the space in "Application Support"; + # older installs (pre-bug-fix) wrote the unquoted form and we still want + # the validator to recognise their JKS pointer as valid for diagnosis. + case "$seen" in + *"trustStore=\"${jks_path}\""*|*"trustStore=${jks_path} "*|*"trustStore=${jks_path}") + ok "$label: $domain launchctl getenv JAVA_TOOL_OPTIONS points at $jks_path" + return 0 + ;; + *) + fail "$label: $domain launchctl getenv JAVA_TOOL_OPTIONS does not point at $jks_path (got: $seen)" + return 1 + ;; + esac +} + +validate_for_user() { + local user="$1" home="$2" + local uid + uid="$(id -u "$user")" + local jks="${home}/${JKS_RELATIVE_DIR}/${JKS_BASENAME}" + local plist="${home}/${LAUNCH_AGENT_RELATIVE_DIR}/${LAUNCH_AGENT_BASENAME}" + + echo "Checking user $user (uid=$uid)..." + validate_keystore_contains_subject "$jks" "$JKS_PASSWORD" "$user truststore" || true + validate_launch_agent_plist "$plist" "$user" || true + validate_launchctl_getenv "$uid" "$jks" "$user" || true +} + +main() { + parse_args "$@" + check_os + + echo "Expected subject (case-insensitive substring): $EXPECTED_SUBJECT" + echo + + if [[ "$ALL_USERS" -eq 1 ]]; then + local iter_count=0 user home + while IFS=$'\t' read -r user home; do + validate_for_user "$user" "$home" + iter_count=$((iter_count + 1)) + done < <(iter_all_users) + if [[ "$iter_count" -eq 0 ]]; then + fail "no eligible users found under /Users/* (UID >= 501)" + fi + else + # Default: validate the invoking user. If invoked via sudo, use SUDO_USER; + # otherwise the current $USER. + local user + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + user="$SUDO_USER" + else + user="$(id -un)" + fi + if [[ "$user" == "root" ]]; then + fail "cannot validate as root without --all-users (no home to inspect)" + else + local home + home="$(get_user_home "$user")" + if [[ -z "$home" || ! -d "$home" ]]; then + fail "home directory not found for $user" + else + validate_for_user "$user" "$home" + fi + fi + fi + + echo "---------------------------------------------------" + if [[ "$FAIL" -eq 0 ]]; then + if [[ "$WARN" -eq 0 ]]; then + echo "Result: All checks passed." + else + # Qualify so a green exit doesn't over-promise. The common case + # is "gui/ domain absent" (validator can't verify launchctl + # getenv without an active GUI session — plist will activate at + # next login). + echo "Result: All checks passed (with $WARN warning(s) — see above)." + fi + exit 0 + else + echo "Result: $FAIL check(s) failed (and $WARN warning(s))." + exit 1 + fi +} + +main "$@"