fix: bundle @rpath dylib deps (libsharpyuv) and fail verify_standalone on unresolvable @rpath references
This commit is contained in:
@@ -130,8 +130,10 @@ defmodule BDS.MacBundle do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Fail the build unless the bundle is self-contained: no Mach-O under the `.app`
|
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
|
may reference a Homebrew/local (`/opt/homebrew`, `/usr/local`) path, and every
|
||||||
hard requirement — the app must run on a Mac without Homebrew installed.
|
`@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()) ::
|
@spec verify_standalone(String.t()) ::
|
||||||
:ok | {:error, {:external_refs, [{String.t(), String.t()}]}}
|
:ok | {:error, {:external_refs, [{String.t(), String.t()}]}}
|
||||||
@@ -141,7 +143,8 @@ defmodule BDS.MacBundle do
|
|||||||
|> macho_files()
|
|> macho_files()
|
||||||
|> Enum.flat_map(fn file ->
|
|> Enum.flat_map(fn file ->
|
||||||
external_refs(file, ["-L"], &Dylibs.parse_otool/1) ++
|
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)
|
end)
|
||||||
|
|
||||||
if offenders == [], do: :ok, else: {:error, {:external_refs, offenders}}
|
if offenders == [], do: :ok, else: {:error, {:external_refs, offenders}}
|
||||||
@@ -154,6 +157,50 @@ defmodule BDS.MacBundle do
|
|||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Compute a `../`-style relative path from `from_dir` to `to` (both absolute),
|
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`.
|
e.g. the path from the wx NIF's directory up to `Contents/Frameworks`.
|
||||||
|
|||||||
@@ -103,16 +103,43 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Build `{old, new}` rewrites for a binary's `otool -L` dependency list: every
|
Resolve an `@rpath/<name>` dependency to absolute candidate paths using the
|
||||||
external dep becomes `<prefix>/<basename>`. System (`/usr/lib`, `/System`) and
|
referencing binary's `LC_RPATH` list, the way dyld would: each rpath entry is
|
||||||
already-relocatable (`@rpath`/`@loader_path`/`@executable_path`) deps are
|
tried in order with `@loader_path` expanded to the binary's own directory.
|
||||||
dropped. Rewriting by basename — not by the exact source path — is what makes
|
`@executable_path` rpaths are skipped (not resolvable from a library), and
|
||||||
this catch every absolute spelling of the same library.
|
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()}]
|
@spec resolve_rpath_dep(String.t(), [String.t()], String.t()) :: [String.t()]
|
||||||
def relink_changes(deps, prefix) when is_list(deps) do
|
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 `<prefix>/<basename>`, and every `@rpath/<name>` 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
|
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)
|
|> Enum.map(fn dep -> {dep, prefix <> "/" <> Path.basename(dep)} end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -135,9 +162,10 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
|
|
||||||
with {:ok, externals} <- collect(nifs, %{}) do
|
with {:ok, externals} <- collect(nifs, %{}) do
|
||||||
reals = materialize(externals, frameworks_dir)
|
reals = materialize(externals, frameworks_dir)
|
||||||
|
bundled = Map.keys(externals)
|
||||||
|
|
||||||
with :ok <- relink_each(reals, fn _real -> "@loader_path" end, set_id: true),
|
with :ok <- relink_each(reals, fn _real -> "@loader_path" end, bundled, set_id: true),
|
||||||
:ok <- relink_each(nifs, prefix_for, set_id: false) do
|
:ok <- relink_each(nifs, prefix_for, bundled, set_id: false) do
|
||||||
{:ok, reals}
|
{:ok, reals}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -147,10 +175,9 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
# basename — different absolute spellings of the same leaf collapse here).
|
# basename — different absolute spellings of the same leaf collapse here).
|
||||||
defp collect(binaries, acc) do
|
defp collect(binaries, acc) do
|
||||||
Enum.reduce_while(binaries, {:ok, acc}, fn bin, {:ok, acc} ->
|
Enum.reduce_while(binaries, {:ok, acc}, fn bin, {:ok, acc} ->
|
||||||
case otool(bin) do
|
case external_deps(bin) do
|
||||||
{:ok, deps} ->
|
{:ok, deps} ->
|
||||||
deps
|
deps
|
||||||
|> Enum.filter(&external?/1)
|
|
||||||
|> Enum.reduce_while({:ok, acc}, fn dep, {:ok, acc} ->
|
|> Enum.reduce_while({:ok, acc}, fn dep, {:ok, acc} ->
|
||||||
base = Path.basename(dep)
|
base = Path.basename(dep)
|
||||||
|
|
||||||
@@ -174,6 +201,42 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
end)
|
end)
|
||||||
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
|
# Copy each external once (deduped by device+inode, which follows symlinks to
|
||||||
# the real file); additional basenames for the same file become symlinks so
|
# the real file); additional basenames for the same file become symlinks so
|
||||||
# dyld loads a single image. Returns the real (non-symlink) copies.
|
# 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}
|
{stat.major_device, stat.inode}
|
||||||
end
|
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)
|
set_id? = Keyword.fetch!(opts, :set_id)
|
||||||
|
|
||||||
Enum.reduce_while(binaries, :ok, fn binary, :ok ->
|
Enum.reduce_while(binaries, :ok, fn binary, :ok ->
|
||||||
with :ok <- maybe_set_id(binary, set_id?),
|
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}
|
{:cont, :ok}
|
||||||
else
|
else
|
||||||
error -> {:halt, error}
|
error -> {:halt, error}
|
||||||
@@ -225,9 +288,9 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
run("install_name_tool", id_args(binary, "@loader_path/" <> Path.basename(binary)))
|
run("install_name_tool", id_args(binary, "@loader_path/" <> Path.basename(binary)))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp relink(binary, prefix) do
|
defp relink(binary, prefix, bundled) do
|
||||||
with {:ok, deps} <- otool(binary),
|
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)
|
strip_external_rpaths(binary)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ defmodule BDS.MacBundleTest do
|
|||||||
alias BDS.MacBundle.Dylibs
|
alias BDS.MacBundle.Dylibs
|
||||||
alias BDS.MacBundle.Icon
|
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.
|
# Parse "#RRGGBB" into {r, g, b} for property assertions in tests.
|
||||||
defp rgb("#" <> hex) do
|
defp rgb("#" <> hex) do
|
||||||
{r, g, b} = {String.slice(hex, 0, 2), String.slice(hex, 2, 2), String.slice(hex, 4, 2)}
|
{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
|
||||||
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
|
describe "Dylibs.parse_rpaths/1" do
|
||||||
test "extracts LC_RPATH paths from otool -l output, ignoring other load commands" do
|
test "extracts LC_RPATH paths from otool -l output, ignoring other load commands" do
|
||||||
output = """
|
output = """
|
||||||
|
|||||||
Reference in New Issue
Block a user