From 8fe18d108f57db8316fc6a6e1b2d8a0beae578b8 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 17 Jul 2026 10:58:51 +0200 Subject: [PATCH] fix: bundle @rpath dylib deps (libsharpyuv) and fail verify_standalone on unresolvable @rpath references --- lib/bds/mac_bundle.ex | 53 ++++++++++++++- lib/bds/mac_bundle/dylibs.ex | 95 ++++++++++++++++++++++----- test/bds/mac_bundle_test.exs | 123 +++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 19 deletions(-) diff --git a/lib/bds/mac_bundle.ex b/lib/bds/mac_bundle.ex index bcc5796..7459d05 100644 --- a/lib/bds/mac_bundle.ex +++ b/lib/bds/mac_bundle.ex @@ -130,8 +130,10 @@ defmodule BDS.MacBundle do @doc """ Fail the build unless the bundle is self-contained: no Mach-O under the `.app` - may reference a Homebrew/local (`/opt/homebrew`, `/usr/local`) path. This is a - hard requirement — the app must run on a Mac without Homebrew installed. + may reference a Homebrew/local (`/opt/homebrew`, `/usr/local`) path, and every + `@rpath/` dependency must resolve — via the binary's own `LC_RPATH`s — to a + file inside the bundle. This is a hard requirement — the app must run on a + Mac without Homebrew installed. """ @spec verify_standalone(String.t()) :: :ok | {:error, {:external_refs, [{String.t(), String.t()}]}} @@ -141,7 +143,8 @@ defmodule BDS.MacBundle do |> macho_files() |> Enum.flat_map(fn file -> external_refs(file, ["-L"], &Dylibs.parse_otool/1) ++ - external_refs(file, ["-l"], &Dylibs.parse_rpaths/1) + external_refs(file, ["-l"], &Dylibs.parse_rpaths/1) ++ + unresolved_rpath_refs(file, app) end) if offenders == [], do: :ok, else: {:error, {:external_refs, offenders}} @@ -154,6 +157,50 @@ defmodule BDS.MacBundle do end end + # An @rpath/ dep is an offender unless one of its rpath-resolved candidates + # exists inside the app — a dangling one (Homebrew's libwebp referencing + # @rpath/libsharpyuv.0.dylib with no copied sibling) crashes only at runtime + # on the user's machine, so it must fail the build here. A dylib's own + # LC_ID_DYLIB shows up in `otool -L` like a dep (precompiled NIFs such as + # hnswlib_nif.so ship an @rpath/ self id with no rpaths at all); dyld never + # resolves a file's id when loading that file, so self references are skipped. + defp unresolved_rpath_refs(file, app) do + root = Path.expand(app) + + case otool_parse(file, ["-L"], &Dylibs.parse_otool/1) do + {:ok, deps} -> + deps + |> Enum.filter(&String.starts_with?(&1, "@rpath/")) + |> Enum.reject(&(Path.basename(&1) == Path.basename(file))) + |> Enum.reject(&rpath_dep_resolves?(file, &1, root)) + |> Enum.map(&{file, &1}) + + _error -> + [] + end + end + + defp rpath_dep_resolves?(file, dep, root) do + case otool_parse(file, ["-l"], &Dylibs.parse_rpaths/1) do + {:ok, rpaths} -> + dep + |> Dylibs.resolve_rpath_dep(rpaths, Path.dirname(file)) + |> Enum.any?(fn candidate -> + String.starts_with?(candidate, root <> "/") and File.exists?(candidate) + end) + + _error -> + false + end + end + + defp otool_parse(file, args, parse) do + case System.cmd("otool", args ++ [file], stderr_to_stdout: true) do + {out, 0} -> {:ok, parse.(out)} + {out, status} -> {:error, {:otool_failed, status, out}} + end + end + @doc """ Compute a `../`-style relative path from `from_dir` to `to` (both absolute), e.g. the path from the wx NIF's directory up to `Contents/Frameworks`. diff --git a/lib/bds/mac_bundle/dylibs.ex b/lib/bds/mac_bundle/dylibs.ex index b4ec37f..799eaac 100644 --- a/lib/bds/mac_bundle/dylibs.ex +++ b/lib/bds/mac_bundle/dylibs.ex @@ -103,16 +103,43 @@ defmodule BDS.MacBundle.Dylibs do end @doc """ - Build `{old, new}` rewrites for a binary's `otool -L` dependency list: every - external dep becomes `/`. System (`/usr/lib`, `/System`) and - already-relocatable (`@rpath`/`@loader_path`/`@executable_path`) deps are - dropped. Rewriting by basename — not by the exact source path — is what makes - this catch every absolute spelling of the same library. + Resolve an `@rpath/` dependency to absolute candidate paths using the + referencing binary's `LC_RPATH` list, the way dyld would: each rpath entry is + tried in order with `@loader_path` expanded to the binary's own directory. + `@executable_path` rpaths are skipped (not resolvable from a library), and + non-`@rpath` deps yield no candidates. Homebrew links keg siblings this way + (`@rpath/libsharpyuv.0.dylib` + rpath `@loader_path/../lib` in libwebp), so + these deps are just as external as absolute `/opt/homebrew` ones. """ - @spec relink_changes([String.t()], String.t()) :: [{String.t(), String.t()}] - def relink_changes(deps, prefix) when is_list(deps) do + @spec resolve_rpath_dep(String.t(), [String.t()], String.t()) :: [String.t()] + def resolve_rpath_dep("@rpath/" <> name, rpaths, loader_dir) when is_list(rpaths) do + rpaths + |> Enum.map(fn + "@loader_path" <> rest -> Path.expand(loader_dir <> rest) + "@executable_path" <> _rest -> nil + absolute -> absolute + end) + |> Enum.reject(&is_nil/1) + |> Enum.map(&Path.join(&1, name)) + end + + def resolve_rpath_dep(_dep, _rpaths, _loader_dir), do: [] + + @doc """ + Build `{old, new}` rewrites for a binary's `otool -L` dependency list: every + external dep becomes `/`, and every `@rpath/` dep + whose basename is in `bundled` (it was copied into Frameworks) is rewritten + the same way. System (`/usr/lib`, `/System`) and other already-relocatable + deps are dropped. Rewriting by basename — not by the exact source path — is + what makes this catch every absolute spelling of the same library. + """ + @spec relink_changes([String.t()], String.t(), [String.t()]) :: [{String.t(), String.t()}] + def relink_changes(deps, prefix, bundled \\ []) when is_list(deps) do deps - |> Enum.filter(&external?/1) + |> Enum.filter(fn + "@rpath/" <> name -> name in bundled + dep -> external?(dep) + end) |> Enum.map(fn dep -> {dep, prefix <> "/" <> Path.basename(dep)} end) end @@ -135,9 +162,10 @@ defmodule BDS.MacBundle.Dylibs do with {:ok, externals} <- collect(nifs, %{}) do reals = materialize(externals, frameworks_dir) + bundled = Map.keys(externals) - with :ok <- relink_each(reals, fn _real -> "@loader_path" end, set_id: true), - :ok <- relink_each(nifs, prefix_for, set_id: false) do + with :ok <- relink_each(reals, fn _real -> "@loader_path" end, bundled, set_id: true), + :ok <- relink_each(nifs, prefix_for, bundled, set_id: false) do {:ok, reals} end end @@ -147,10 +175,9 @@ defmodule BDS.MacBundle.Dylibs do # basename — different absolute spellings of the same leaf collapse here). defp collect(binaries, acc) do Enum.reduce_while(binaries, {:ok, acc}, fn bin, {:ok, acc} -> - case otool(bin) do + case external_deps(bin) do {:ok, deps} -> deps - |> Enum.filter(&external?/1) |> Enum.reduce_while({:ok, acc}, fn dep, {:ok, acc} -> base = Path.basename(dep) @@ -174,6 +201,42 @@ defmodule BDS.MacBundle.Dylibs do end) end + # A binary's external deps: absolute Homebrew/local references, plus any + # @rpath/ reference that resolves (via the binary's own LC_RPATHs) to an + # existing Homebrew/local file. @rpath deps resolving inside the release tree + # (vix/exla-style precompiled NIFs) are relocatable as-is and stay untouched. + defp external_deps(bin) do + with {:ok, deps} <- otool(bin) do + direct = Enum.filter(deps, &external?/1) + + case resolve_external_rpath_deps(bin, deps) do + {:ok, resolved} -> {:ok, direct ++ resolved} + error -> error + end + end + end + + defp resolve_external_rpath_deps(bin, deps) do + case Enum.filter(deps, &String.starts_with?(&1, "@rpath/")) do + [] -> + {:ok, []} + + rpath_deps -> + with {:ok, search_paths} <- rpaths(bin) do + resolved = + rpath_deps + |> Enum.map(fn dep -> + dep + |> resolve_rpath_dep(search_paths, Path.dirname(bin)) + |> Enum.find(fn candidate -> external?(candidate) and File.exists?(candidate) end) + end) + |> Enum.reject(&is_nil/1) + + {:ok, resolved} + end + end + end + # Copy each external once (deduped by device+inode, which follows symlinks to # the real file); additional basenames for the same file become symlinks so # dyld loads a single image. Returns the real (non-symlink) copies. @@ -206,12 +269,12 @@ defmodule BDS.MacBundle.Dylibs do {stat.major_device, stat.inode} end - defp relink_each(binaries, prefix_for, opts) do + defp relink_each(binaries, prefix_for, bundled, opts) do set_id? = Keyword.fetch!(opts, :set_id) Enum.reduce_while(binaries, :ok, fn binary, :ok -> with :ok <- maybe_set_id(binary, set_id?), - :ok <- relink(binary, prefix_for.(binary)) do + :ok <- relink(binary, prefix_for.(binary), bundled) do {:cont, :ok} else error -> {:halt, error} @@ -225,9 +288,9 @@ defmodule BDS.MacBundle.Dylibs do run("install_name_tool", id_args(binary, "@loader_path/" <> Path.basename(binary))) end - defp relink(binary, prefix) do + defp relink(binary, prefix, bundled) do with {:ok, deps} <- otool(binary), - :ok <- apply_changes(binary, change_args(binary, relink_changes(deps, prefix))) do + :ok <- apply_changes(binary, change_args(binary, relink_changes(deps, prefix, bundled))) do strip_external_rpaths(binary) end end diff --git a/test/bds/mac_bundle_test.exs b/test/bds/mac_bundle_test.exs index f255cb0..cd0ae60 100644 --- a/test/bds/mac_bundle_test.exs +++ b/test/bds/mac_bundle_test.exs @@ -7,6 +7,19 @@ defmodule BDS.MacBundleTest do alias BDS.MacBundle.Dylibs alias BDS.MacBundle.Icon + # Give a copied keg dylib a controlled id so verify_standalone only sees the + # reference under test, not the keg's absolute LC_ID_DYLIB. + defp fix_id!(dylib, id \\ nil) do + {_out, 0} = + System.cmd( + "install_name_tool", + Dylibs.id_args(dylib, id || "@loader_path/" <> Path.basename(dylib)), + stderr_to_stdout: true + ) + + :ok + end + # Parse "#RRGGBB" into {r, g, b} for property assertions in tests. defp rgb("#" <> hex) do {r, g, b} = {String.slice(hex, 0, 2), String.slice(hex, 2, 2), String.slice(hex, 4, 2)} @@ -176,6 +189,116 @@ defmodule BDS.MacBundleTest do end end + describe "Dylibs.resolve_rpath_dep/3" do + test "expands @loader_path rpaths against the referencing binary's directory" do + assert Dylibs.resolve_rpath_dep( + "@rpath/libsharpyuv.0.dylib", + ["@loader_path/../lib", "/opt/homebrew/lib"], + "/opt/homebrew/opt/webp/lib" + ) == [ + "/opt/homebrew/opt/webp/lib/libsharpyuv.0.dylib", + "/opt/homebrew/lib/libsharpyuv.0.dylib" + ] + end + + test "resolves in-tree loader-relative rpaths (vix/exla precompiled layout)" do + assert Dylibs.resolve_rpath_dep( + "@rpath/libvips.42.dylib", + ["@loader_path/precompiled_libvips/lib"], + "/app/rel/lib/vix-0.38.0/priv" + ) == ["/app/rel/lib/vix-0.38.0/priv/precompiled_libvips/lib/libvips.42.dylib"] + end + + test "skips @executable_path rpaths (not resolvable from a library) and non-@rpath deps" do + assert Dylibs.resolve_rpath_dep( + "@rpath/libfoo.dylib", + ["@executable_path/../Frameworks"], + "/any/dir" + ) == [] + + assert Dylibs.resolve_rpath_dep("/usr/lib/libSystem.B.dylib", ["@loader_path"], "/d") == [] + end + end + + describe "Dylibs.relink_changes/3 (@rpath deps)" do + test "rewrites @rpath deps whose basename was bundled, leaves the rest" do + deps = [ + "/opt/homebrew/opt/webp/lib/libwebp.7.dylib", + "@rpath/libsharpyuv.0.dylib", + "@rpath/libvips.42.dylib", + "/usr/lib/libSystem.B.dylib" + ] + + assert Dylibs.relink_changes(deps, "@loader_path", ["libsharpyuv.0.dylib"]) == [ + {"/opt/homebrew/opt/webp/lib/libwebp.7.dylib", "@loader_path/libwebp.7.dylib"}, + {"@rpath/libsharpyuv.0.dylib", "@loader_path/libsharpyuv.0.dylib"} + ] + end + end + + describe "MacBundle.verify_standalone/1 (@rpath resolution gate)" do + @describetag :macos_tools + + # Regression for the July 2026 crash: Homebrew's libwebp references its + # sibling as @rpath/libsharpyuv.0.dylib (rpath @loader_path/../lib). A copy + # in Frameworks without the sibling must fail verification; a copy whose + # rpath resolves inside the tree must pass. + test "fails on an @rpath dep that does not resolve inside the bundle and passes when it does" do + keg_webp = "/opt/homebrew/opt/webp/lib/libwebp.7.dylib" + keg_sharpyuv = "/opt/homebrew/opt/webp/lib/libsharpyuv.0.dylib" + + if File.exists?(keg_webp) and File.exists?(keg_sharpyuv) do + tmp = Path.join(System.tmp_dir!(), "bds-rpath-#{System.unique_integer([:positive])}") + on_exit(fn -> File.rm_rf!(tmp) end) + + # Broken layout: libwebp in Frameworks, libsharpyuv missing entirely. + broken = Path.join(tmp, "broken/Contents/Frameworks") + File.mkdir_p!(broken) + File.cp!(keg_webp, Path.join(broken, "libwebp.7.dylib")) + File.chmod!(Path.join(broken, "libwebp.7.dylib"), 0o644) + fix_id!(Path.join(broken, "libwebp.7.dylib")) + + assert {:error, {:external_refs, offenders}} = + MacBundle.verify_standalone(Path.join(tmp, "broken")) + + assert Enum.any?(offenders, fn {_file, ref} -> ref == "@rpath/libsharpyuv.0.dylib" end) + + # Healthy layout: @loader_path/../lib resolves next to the library, so + # placing the sibling there satisfies the reference (vix-style in-tree + # resolution must not be flagged). libsharpyuv keeps an @rpath/ self id + # — precompiled NIF deps (hnswlib, libvips, libmlx) ship exactly that, + # and a library's own LC_ID_DYLIB must never count as unresolved. + healthy = Path.join(tmp, "healthy/Contents/pkg/lib") + File.mkdir_p!(healthy) + File.cp!(keg_webp, Path.join(healthy, "libwebp.7.dylib")) + File.cp!(keg_sharpyuv, Path.join(healthy, "libsharpyuv.0.dylib")) + Enum.each(["libwebp.7.dylib", "libsharpyuv.0.dylib"], fn base -> + File.chmod!(Path.join(healthy, base), 0o644) + end) + + fix_id!(Path.join(healthy, "libwebp.7.dylib")) + fix_id!(Path.join(healthy, "libsharpyuv.0.dylib"), "@rpath/libsharpyuv.0.dylib") + + # Match the precompiled-NIF shape exactly: @rpath/ self id with NO + # LC_RPATH left to resolve it (hnswlib_nif.so ships like this). + sharpyuv = Path.join(healthy, "libsharpyuv.0.dylib") + + {rpath_out, 0} = System.cmd("otool", ["-l", sharpyuv], stderr_to_stdout: true) + + Enum.each(Dylibs.parse_rpaths(rpath_out), fn rpath -> + {_out, 0} = + System.cmd("install_name_tool", ["-delete_rpath", rpath, sharpyuv], + stderr_to_stdout: true + ) + end) + + assert MacBundle.verify_standalone(Path.join(tmp, "healthy")) == :ok + else + :ok + end + end + end + describe "Dylibs.parse_rpaths/1" do test "extracts LC_RPATH paths from otool -l output, ignoring other load commands" do output = """