diff --git a/.gitignore b/.gitignore index f2b7de1..559a886 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .DS_Store *Manifest*.toml -.vscode \ No newline at end of file +.vscode +test/elf/liboracle.so +test/elf/libclient.so +test/elf/*.o diff --git a/Project.toml b/Project.toml index 43cddaa..a9cf53d 100644 --- a/Project.toml +++ b/Project.toml @@ -9,7 +9,6 @@ Libdl = "8f399da3-3557-5675-b5ff-fb832c97cbdb" Mmap = "a63ad114-7e13-5084-954f-fe012c677804" ObjectFile = "d8793406-e978-5875-9003-1fc021f44a92" PackageCompiler = "9b87118b-4619-50d2-8e1e-99f35a4d4d9d" -Patchelf_jll = "f2cf89d6-2bfd-5c44-bd2c-068eea195c0c" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" RelocatableFolders = "05181044-ff0b-4ac5-8273-598c1e38db00" StructIO = "53d494c1-5632-5724-8f4c-31dff12d585f" @@ -30,7 +29,8 @@ julia = "1.10" [extras] JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +Patchelf_jll = "f2cf89d6-2bfd-5c44-bd2c-068eea195c0c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "JSON"] +test = ["Test", "JSON", "Patchelf_jll"] diff --git a/src/patchversion.jl b/src/patchversion.jl index 172afda..042182b 100644 --- a/src/patchversion.jl +++ b/src/patchversion.jl @@ -1,6 +1,6 @@ module PatchVersion -export patch_version, patch_version! +export patch_version, patch_version!, read_soname, read_needed, set_soname!, replace_needed! import ObjectFile: ObjectFile, ELFHandle, StrTab, SectionRef, Sections, strtab_lookup, ELF, section_offset, section_size @@ -21,7 +21,10 @@ function patch_str!(tab::SectionRef, pos, newstr::Vector{UInt8}) old = readuntil(ObjectFile.handle(tab), UInt8(0)) pad = length(old) - length(newstr) if pad < 0 - error(string("Length mismatch; can't overwrite $old with $newstr")) + error(""" + cannot patch ELF string table in place: replacement $(repr(String(copy(newstr)))) \ + ($(length(newstr)) bytes) is longer than the existing entry $(repr(String(copy(old)))) \ + ($(length(old)) bytes). In-place .dynstr patching can only shrink or keep length, never grow.""") elseif pad > 0 newstr = vcat(newstr, fill(UInt8(0), pad)) end @@ -62,10 +65,13 @@ Update version strings and hashes in-place. Touches the .dynstr, .gnu.version_d function patch_version!(infile::AbstractString, oldver::Vector{UInt8}, newver::Vector{UInt8}; patch_def=true, patch_need=true, patch_strtab=true) if length(oldver) < length(newver) - error(string("Length mismatch; can't overwrite $oldver with $newver")) + error(""" + cannot patch version string in place: new version $(repr(String(copy(newver)))) \ + ($(length(newver)) bytes) is longer than old version $(repr(String(copy(oldver)))) \ + ($(length(oldver)) bytes); the replacement must be the same length or shorter.""") end - open(infile, read=true, write=true, create=false, truncate=false) do io + open(infile; read=true, write=true, create=false, truncate=false) do io oh = only(ObjectFile.readmeta(io)) tab = findfirst(Sections(oh), ".dynstr") @@ -141,4 +147,83 @@ end patch_version(i, o, n, out; kwargs...) = patch_version(i, Vector{UInt8}(o), Vector{UInt8}(n), out; kwargs...) patch_version!(i, o, n; kwargs...) = patch_version!(i, Vector{UInt8}(o), Vector{UInt8}(n); kwargs...) +# DT_SONAME / DT_NEEDED in-place patching via patch_str! (same-length or shorter; never grows). + +# The .dynstr section a dynamic entry's string lives in (via the .dynamic sh_link). +_dynstr_section(oh, d) = Sections(oh)[ObjectFile.deref(ObjectFile.Section(d)).sh_link + 1] + +# Overwrite dynamic entry `d`'s .dynstr string with `newstr` in place (errors on grow or overlap). +function _patch_dyn_string!(oh::ELFHandle, d, newstr::Vector{UInt8}) + # Any ELF class/endianness: ObjectFile.jl decodes fields per the header; string bytes are raw ASCII. + pos = Int(ObjectFile.deref(d).d_val) + tab = _dynstr_section(oh, d) + seek(tab, pos) + oldlen = length(readuntil(ObjectFile.handle(tab), UInt8(0))) + # Refuse if the target's [string+NUL] range overlaps any other .dynstr record (tail-merge: target is a longer string's shared suffix, so even an equal-length overwrite corrupts it). + seek(ObjectFile.handle(tab).io, section_offset(tab)) + bytes = read(ObjectFile.handle(tab).io, Int(section_size(tab))) + start = 0 + for i in 0:length(bytes)-1 + bytes[i + 1] == 0x00 || continue + rlen = i - start + if rlen > 0 && start != pos && !(start + rlen < pos || start > pos + oldlen) + error("Refusing in-place .dynstr patch at $pos [len $oldlen]: overlaps string at $start (tail-merged string table)") + end + start = i + 1 + end + patch_str!(tab, pos, newstr) + return nothing +end + +""" + read_soname(file)::Union{String,Nothing} + +Return the `DT_SONAME` string of an ELF shared object, or `nothing` if it has none. +""" +read_soname(file) = open(file) do io + oh = only(ObjectFile.readmeta(io)) + es = ELF.ELFDynEntries(oh, [ELF.DT_SONAME]) + isempty(es) ? nothing : String(strtab_lookup(only(es))) +end + +""" + read_needed(file)::Vector{String} + +Return all `DT_NEEDED` entries of an ELF shared object, in order. +""" +read_needed(file) = open(file) do io + oh = only(ObjectFile.readmeta(io)) + String[String(strtab_lookup(d)) for d in ELF.ELFDynEntries(oh, [ELF.DT_NEEDED])] +end + +""" + set_soname!(file, newname) + +Rewrite the `DT_SONAME` of an ELF shared object in place. Errors if `file` has +no `DT_SONAME` or if `newname` is longer than the current soname. +""" +function set_soname!(file, newname) + open(file; read=true, write=true, create=false, truncate=false) do io + oh = only(ObjectFile.readmeta(io)) + es = ELF.ELFDynEntries(oh, [ELF.DT_SONAME]) + isempty(es) && error("no DT_SONAME in $file") + _patch_dyn_string!(oh, only(es), Vector{UInt8}(newname)) + end +end + +""" + replace_needed!(file, oldname, newname) + +Rename the single `DT_NEEDED` entry equal to `oldname` to `newname`, in place. +Asserts exactly one entry matches `oldname`; errors if `newname` is longer. +""" +function replace_needed!(file, oldname, newname) + open(file; read=true, write=true, create=false, truncate=false) do io + oh = only(ObjectFile.readmeta(io)) + matches = filter(d -> strtab_lookup(d) == oldname, ELF.ELFDynEntries(oh, [ELF.DT_NEEDED])) + @assert length(matches) == 1 "expected exactly one DT_NEEDED == \"$oldname\" in $file, got $(length(matches))" + _patch_dyn_string!(oh, only(matches), Vector{UInt8}(newname)) + end +end + end diff --git a/src/privatize_common.jl b/src/privatize_common.jl index 7f6e014..cc70b9c 100644 --- a/src/privatize_common.jl +++ b/src/privatize_common.jl @@ -10,13 +10,70 @@ abstract type PrivatizePlatform end struct MacOSPlatform <: PrivatizePlatform end struct LinuxPlatform <: PrivatizePlatform end +""" + SaltMap + +A single, explicit description of how every library name is rewritten during +privatization. It is built once per run (see `build_salt_map`) and threaded +through all rewrite sites, so the salted form of a name is derived in exactly +one place. + +Fields: +- `salt`: the raw salt token, retained only as a record of this run for + logging/debugging. It is *not* re-applied to names anywhere downstream — all + salting goes through `rename`. +- `rename`: the platform's name-salting function, `name -> salted_name`. It is + total: it works on full basenames (`libjulia.so.1.13`), bare SONAME/NEEDED + strings, and on the extension-less stems that appear inside `dep_libs` + (`libjulia`, `libjulia-internal`). This is the only definition of "how a name + is salted". +- `basenames`: the concrete map of `original_basename => salted_basename` for + every real library file actually discovered on disk, plus any symlink + basenames that resolve to one of them. This is what we consult when we need to + know *which* names were salted (dependency / NEEDED rewrites, symlink + retargeting) rather than how to salt an arbitrary name. + +`rename` and `basenames` are consistent by construction: every value in +`basenames` equals `rename` applied to its key. +""" +struct SaltMap{F} + salt::String + rename::F + basenames::Dict{String,String} +end + +# Apply the platform's name-salting to one name (the single definition of "salting a name"). +salt_name(m::SaltMap, name::AbstractString) = m.rename(String(name)) + +# Record that real library basename `base` is salted, returning the salted basename. +function record!(m::SaltMap, base::AbstractString) + salted = salt_name(m, base) + m.basenames[String(base)] = salted + return salted +end + # Per-platform hooks (implemented in platform-specific files) plat_ext(::PrivatizePlatform) = error("Unsupported platform") -plat_dep_prefix(::PrivatizePlatform) = error("Unsupported platform") -plat_set_library_id!(::PrivatizePlatform, libpath::String, new_id::String) = nothing -plat_install_name_change!(::PrivatizePlatform, binpath::String, old::String, new::String) = error("Unsupported platform change") + +# How a dependency is referenced in load metadata (macOS `@rpath/`, Linux bare ``); applied uniformly to the id and every dependency reference. +plat_dep_ref(::PrivatizePlatform, name::String) = error("Unsupported platform") + +# Build this run's name-salting function (`name -> salted_name`); the only place salt becomes a transform — macOS prepends (may grow), Linux substitutes same-length. +plat_make_renamer(::PrivatizePlatform, salt::String) = error("Unsupported platform") + +# Set a salted library's own identity (install_name id / SONAME), resolved from the `SaltMap`: macOS uses `@rpath/`, Linux salts the current SONAME. +plat_set_library_id!(::PrivatizePlatform, smap, libpath::String) = nothing + +# Rewrite one dependency reference in `binpath` from `old` to `new` (both already fully-resolved). +plat_install_name_change!(::PrivatizePlatform, binpath::String, old::String, new::String) = + error("Unsupported platform change") + plat_get_deps(::PrivatizePlatform, bin::String) = String[] +# Build the SaltMap for a run from the platform's renamer. +build_salt_map(platform::PrivatizePlatform, salt::String) = + SaltMap(salt, plat_make_renamer(platform, salt), Dict{String,String}()) + function privatize_libjulia_common!(recipe::BundleRecipe, platform::PrivatizePlatform) bundle_root = recipe.output_dir product = recipe.link_recipe.outname @@ -46,24 +103,23 @@ function privatize_libjulia_common!(recipe::BundleRecipe, platform::PrivatizePla isempty(real_files) && return salt = random_salt(8) + smap = build_salt_map(platform, salt) salted_paths = Dict{String,String}() - salted_filenames = Dict{String,String}() originals_to_remove = String[] # 1) Salt all real library files for p in real_files base = basename(p) - salted_base = string(salt, "_", base) + salted_base = record!(smap, base) # records base => salted_base in the map salted_path = joinpath(dirname(p), salted_base) cp(p, salted_path; force=true) chmod(salted_path, filemode(salted_path) | 0o200) # ensure writable for patching push!(originals_to_remove, p) - # Update library identity for salted copy (install_name/SONAME) via unified hook - plat_set_library_id!(platform, salted_path, plat_dep_prefix(platform) * salted_base) + # Update the salted copy's identity (install_name/SONAME); the platform resolves the new id from the shared map. + plat_set_library_id!(platform, smap, salted_path) salted_paths[p] = salted_path - salted_filenames[base] = salted_base if startswith(base, "libjulia.") && !islink(salted_path) - replace_dep_libs(salted_path, salt) + replace_dep_libs(salted_path, smap) end end @@ -73,9 +129,9 @@ function privatize_libjulia_common!(recipe::BundleRecipe, platform::PrivatizePla link_base = basename(lnk) target = readlink(lnk) target_base = basename(target) - haskey(salted_filenames, target_base) || continue - salted_target_base = salted_filenames[target_base] - salted_link = joinpath(dir, string(salt, "_", link_base)) + haskey(smap.basenames, target_base) || continue + salted_target_base = smap.basenames[target_base] + salted_link = joinpath(dir, salt_name(smap, link_base)) try symlink(salted_target_base, salted_link) catch e @@ -86,9 +142,8 @@ function privatize_libjulia_common!(recipe::BundleRecipe, platform::PrivatizePla error("Failed to create symlink $salted_link -> $salted_target_base", e) end end - # Record that this symlink basename should be rewritten to the salted target - # This is necessary because the DT_NEEDED entries may point to a symlink - salted_filenames[link_base] = salted_target_base + # Record the symlink basename so DT_NEEDED entries that name the symlink are recognized as bundled (and thus rewritten). + smap.basenames[link_base] = salted_target_base # Schedule original symlink for removal push!(originals_to_remove, lnk) end @@ -97,7 +152,7 @@ function privatize_libjulia_common!(recipe::BundleRecipe, platform::PrivatizePla all_targets = collect(values(salted_paths)) push!(all_targets, product) for t in unique(all_targets) - reps = replacements_for(t, salted_filenames, platform) + reps = replacements_for(t, smap, platform) if recipe.link_recipe.image_recipe.verbose && !isempty(reps) println("Privatize: updating deps for ", t) for (old,new) in reps @@ -119,7 +174,15 @@ end const DEP_LIBS_LENGTH = 512 # This is technically 1024 bytes, but we use 512 to be safe -function replace_dep_libs(file, salt) +""" + replace_dep_libs(file, smap::SaltMap) + +Rewrite the `dep_libs` string (a NUL/colon list of bare library stems such as +`libjulia:libjulia-internal`) in place, salting each stem with the same +`smap.rename` used for every other name. macOS's renamer may grow the stems +(absorbed by the fixed-size buffer); Linux's renamer is length-preserving. +""" +function replace_dep_libs(file, smap::SaltMap) obj = only(readmeta(open(file, "r"))) syms = collect(Symbols(obj)) syms_names = symbol_name.(syms) @@ -128,17 +191,24 @@ function replace_dep_libs(file, salt) fileh = open(file, "r+") filem = Mmap.mmap(fileh) data = String(filem[offset : (offset + DEP_LIBS_LENGTH - 1)]) - new_data = Vector{UInt8}(replace(data, "libjulia" => salt * "_" * "libjulia")[begin:DEP_LIBS_LENGTH]) + new = salt_dep_libs(data, smap) + new_data = Vector{UInt8}(new[begin:DEP_LIBS_LENGTH]) filem[offset : (offset + DEP_LIBS_LENGTH - 1)] .= new_data Mmap.sync!(filem) end -function replacements_for(bin::String, salted_filenames::Dict{String,String}, platform::PrivatizePlatform) +# Salt each stem in the colon-separated, NUL-padded `dep_libs` blob with the platform renamer (no per-platform branch here). +function salt_dep_libs(data::AbstractString, smap::SaltMap) + return replace(data, r"[^:\0]+" => m -> salt_name(smap, m)) +end + +function replacements_for(bin::String, smap::SaltMap, platform::PrivatizePlatform) seen = Set{Tuple{String,String}}() for dep in plat_get_deps(platform, bin) b = basename(dep) - if haskey(salted_filenames, b) - new_dep = plat_dep_prefix(platform) * salted_filenames[b] + if haskey(smap.basenames, b) + # Salt the live dep string (not the recorded basename) so a soname like libjulia.so.1.12 maps to .so.1.12 — length-preserving; basenames is only the "bundled?" guard. + new_dep = plat_dep_ref(platform, salt_name(smap, b)) push!(seen, (dep, new_dep)) end end diff --git a/src/privatize_linux.jl b/src/privatize_linux.jl index d22ba2b..bee9122 100644 --- a/src/privatize_linux.jl +++ b/src/privatize_linux.jl @@ -3,7 +3,7 @@ Linux-specific privatization for libjulia. High-level steps: 1) Copy `libjulia*` and `libjulia-internal*` to salted basenames next to originals. -2) Set SONAME of each salted library to the salted basename (via patchelf) and DEP_LIBS with ObjectFile.jl +2) Set SONAME of each salted library to the salted basename (in-place, pure-Julia ELF patching) and DEP_LIBS with ObjectFile.jl 3) Rewrite DT_NEEDED entries in the built artifact and salted libs to the salted basenames (no `@rpath` on Linux; DT_NEEDED entries are plain basenames). 4) Recreate symlinks @@ -11,37 +11,33 @@ High-level steps: 6) Remove originals. """ -using Patchelf_jll - function privatize_libjulia_linux!(recipe::BundleRecipe) - try - salted_paths = privatize_libjulia_common!(recipe, LinuxPlatform()) - - # Version-stamp symbol versions to avoid interposition (Linux-specific) - if salted_paths !== nothing - try - version_stamp_symbols!(salted_paths, recipe.link_recipe.outname) - catch e - error("Failed to patch symbol versions on salted libraries", e) - end - end - catch e - error("Failed to privatize libjulia on Linux", e) + salted_paths = privatize_libjulia_common!(recipe, LinuxPlatform()) + + # Version-stamp symbol versions to avoid interposition (Linux-specific) + if salted_paths !== nothing + version_stamp_symbols!(salted_paths, recipe.link_recipe.outname) end end -# Linux-specific dependency extraction -function get_dependencies_linux(bin::String) - out = read(`$(Patchelf_jll.patchelf()) --print-needed $(bin)`, String) - return filter(!isempty, split(out, '\n')) -end +# Linux-specific dependency extraction (pure-Julia, via PatchVersion). +get_dependencies_linux(bin::String) = PatchVersion.read_needed(bin) + +# Substitute the 8-char "libjulia" token with the 8-char salt (length-preserving): Linux's one name-salting function for basenames, SONAMEs, NEEDED and dep_libs stems. +_salt_julia_name(name::AbstractString, salt::String) = + replace(String(name), "libjulia" => salt; count = 1) -function patchelf_replace_needed!(binpath::String, old::String, new::String) - run(`$(Patchelf_jll.patchelf()) --replace-needed $(old) $(new) $(binpath)`) +# Rename the libjulia DT_NEEDED entry `old` to the already-salted `new`, in place. +function replace_needed_salted!(binpath::String, old::String, new::String) + @assert occursin("libjulia", old) "refusing to rewrite DT_NEEDED \"$old\" in $binpath: not a libjulia entry" + PatchVersion.replace_needed!(binpath, old, new) end -function patchelf_set_soname!(libpath::String, soname::String) - run(`$(Patchelf_jll.patchelf()) --set-soname $(soname) $(libpath)`) +# Set `libpath`'s SONAME to the salted form of its current SONAME (e.g. `libjulia.so.1.12`, which can differ from the basename), via the map's renamer so it stays length-preserving. +function set_soname_salted!(smap::SaltMap, libpath::String) + current = PatchVersion.read_soname(libpath) + @assert current !== nothing && occursin("libjulia", current) "refusing to set SONAME of $libpath: current soname $(repr(current)) is not a libjulia name" + PatchVersion.set_soname!(libpath, salt_name(smap, current)) end function version_stamp_symbols!(salted_paths::Dict{String,String}, product::String) @@ -55,8 +51,11 @@ end # Platform hooks for Linux plat_ext(::LinuxPlatform) = ".so" -plat_dep_prefix(::LinuxPlatform) = "" -plat_set_library_id!(::LinuxPlatform, libpath::String, new_id::String) = patchelf_set_soname!(libpath, basename(new_id)) -plat_install_name_change!(::LinuxPlatform, binpath::String, old::String, new::String) = patchelf_replace_needed!(binpath, old, new) +# DT_NEEDED / SONAME strings are bare names on Linux (no @rpath). +plat_dep_ref(::LinuxPlatform, name::String) = name +# Linux's renamer: equal-length "libjulia" token substitution (not prepend), so in-place .dynstr patches stay length-preserving. +plat_make_renamer(::LinuxPlatform, salt::String) = name -> _salt_julia_name(name, salt) +plat_set_library_id!(::LinuxPlatform, smap::SaltMap, libpath::String) = set_soname_salted!(smap, libpath) +plat_install_name_change!(::LinuxPlatform, binpath::String, old::String, new::String) = replace_needed_salted!(binpath, old, new) plat_get_deps(::LinuxPlatform, bin::String) = get_dependencies_linux(bin) diff --git a/src/privatize_macos.jl b/src/privatize_macos.jl index 1f8762b..9a8615b 100644 --- a/src/privatize_macos.jl +++ b/src/privatize_macos.jl @@ -46,10 +46,18 @@ function install_name_change!(binpath::String, old_id::String, new_id::String) run(`install_name_tool -change $(old_id) $(new_id) $(binpath)`) end +# macOS's renamer prepends `_` to the libjulia token (install_name_tool can grow strings); names without the token are left untouched. +_salt_julia_name_macos(name::AbstractString, salt::String) = + replace(String(name), "libjulia" => string(salt, "_", "libjulia")) + # Platform hooks for macOS plat_ext(::MacOSPlatform) = ".dylib" -plat_dep_prefix(::MacOSPlatform) = "@rpath/" -plat_set_library_id!(::MacOSPlatform, libpath::String, new_id::String) = install_name_id!(libpath, new_id) +# Dependencies are referenced via @rpath on macOS. +plat_dep_ref(::MacOSPlatform, name::String) = string("@rpath/", name) +plat_make_renamer(::MacOSPlatform, salt::String) = name -> _salt_julia_name_macos(name, salt) +# macOS sets the install_name id to @rpath/. +plat_set_library_id!(::MacOSPlatform, smap::SaltMap, libpath::String) = + install_name_id!(libpath, plat_dep_ref(MacOSPlatform(), basename(libpath))) plat_install_name_change!(::MacOSPlatform, binpath::String, old::String, new::String) = install_name_change!(binpath, old, new) plat_get_deps(::MacOSPlatform, bin::String) = get_dependencies_macos(bin) diff --git a/test/elf/Makefile b/test/elf/Makefile new file mode 100644 index 0000000..dfe46ef --- /dev/null +++ b/test/elf/Makefile @@ -0,0 +1,16 @@ +all: liboracle.so libclient.so liboracle-bak.so libclient-bak.so +clean: + rm -f *.o *.so + +CC=gcc -g -Wall + +liboracle.so: liboracle.o + $(CC) -Wl,--version-script,liboracle.map -Wl,-soname,liboracle.so -shared -o $@ $^ + +libclient.so: liboracle.so client.c + $(CC) -Wl,-rpath='$$ORIGIN',-z,origin -Wl,-soname,libclient.so -shared $^ -L. -loracle -o $@ + +%-bak.so: %.so + cp $< $@ + +.PHONY: all clean diff --git a/test/elf/client.c b/test/elf/client.c new file mode 100644 index 0000000..e909777 --- /dev/null +++ b/test/elf/client.c @@ -0,0 +1,22 @@ +#include + +extern int g1(); +extern int g2(); +extern int g3(); + +int g1_caller(){ + return g1(); +}; +int g2_caller(){ + return g2(); +}; +int g3_caller(){ + return g3(); +}; + +int client_main(void){ + printf("%d\n", g1()); + printf("%d\n", g2()); + printf("%d\n", g3()); + return g1(); +} diff --git a/test/elf/libclient-bak.so b/test/elf/libclient-bak.so new file mode 100755 index 0000000..3438f38 Binary files /dev/null and b/test/elf/libclient-bak.so differ diff --git a/test/elf/liboracle-bak.so b/test/elf/liboracle-bak.so new file mode 100755 index 0000000..9f27186 Binary files /dev/null and b/test/elf/liboracle-bak.so differ diff --git a/test/elf/liboracle.c b/test/elf/liboracle.c new file mode 100644 index 0000000..3fd1107 --- /dev/null +++ b/test/elf/liboracle.c @@ -0,0 +1,15 @@ +__asm__(".symver g1_0,g1@"); +__asm__(".symver g1_1_1,g1@LIBORACLE_1.1"); +__asm__(".symver g1_1_2,g1@LIBORACLE_1.2"); +__asm__(".symver g1_2_0,g1@@LIBORACLE_2.0"); + +int g1_0(void) {return 100;} +int g1_1_1(void) {return 111;} +int g1_1_2(void) {return 112;} +int g1_2_0(void) {return 120;} + +/* only defined in 1.2 */ +int g2(void) {return 212;} + +/* only defined in 2.0 */ +int g3(void) {return 320;} diff --git a/test/elf/liboracle.map b/test/elf/liboracle.map new file mode 100644 index 0000000..9f9c759 --- /dev/null +++ b/test/elf/liboracle.map @@ -0,0 +1,16 @@ +LIBORACLE_1.1 { + global: + g1; + local: + *; +}; + +LIBORACLE_1.2 { + global: + g2; +} LIBORACLE_1.1; + +LIBORACLE_2.0 { + global: + g3; +} LIBORACLE_1.2; diff --git a/test/elf_ops.jl b/test/elf_ops.jl new file mode 100644 index 0000000..84ea129 --- /dev/null +++ b/test/elf_ops.jl @@ -0,0 +1,151 @@ +# Pure-Julia ELF read/patch ops (PatchVersion): verified against patchelf as a +# read-only oracle and via end-to-end dlopen of substituted (renamed) libraries. +using Test +import JuliaC.PatchVersion: read_soname, read_needed, set_soname!, replace_needed! +import Base.Libc.Libdl +import Libdl.dlopen, Libdl.dlsym, Libdl.dlclose +using Patchelf_jll: patchelf + +const ELFDIR = joinpath(@__DIR__, "elf") + +# patchelf as an independent oracle. +pe(args...) = strip(read(`$(patchelf()) $(collect(String.(args)))`, String)) + +dlsymcall(handle, sym) = ccall(dlsym(handle, sym), Cint, ()) + +function reset_libs() + # Originals are produced by test/elf/Makefile and committed as *-bak.so. + cp(joinpath(ELFDIR, "liboracle-bak.so"), joinpath(ELFDIR, "liboracle.so"); force=true) + cp(joinpath(ELFDIR, "libclient-bak.so"), joinpath(ELFDIR, "libclient.so"); force=true) + chmod(joinpath(ELFDIR, "liboracle.so"), 0o755) + chmod(joinpath(ELFDIR, "libclient.so"), 0o755) +end + +if Sys.islinux() + push!(Base.DL_LOAD_PATH, ELFDIR) + + @testset "read_soname / read_needed agree with patchelf" begin + reset_libs() + n = joinpath(ELFDIR, "liboracle.so") + c = joinpath(ELFDIR, "libclient.so") + @test read_soname(n) == "liboracle.so" + @test read_soname(n) == pe("--print-soname", n) + @test read_soname(n) isa String + @test read_needed(c) isa Vector{String} + @test Set(read_needed(c)) == Set(split(pe("--print-needed", c), "\n")) + reset_libs() + end + + @testset "set_soname! (same-length substitution) + patchelf oracle" begin + reset_libs() + n = joinpath(ELFDIR, "liboracle.so") + newname = replace("liboracle.so", "oracle" => "Zq7Kp2") # 6 == 6 chars + @test length(newname) == length("liboracle.so") + set_soname!(n, newname) + @test read_soname(n) == newname + @test pe("--print-soname", n) == newname + reset_libs() + end + + @testset "replace_needed! changes exactly one entry + patchelf oracle" begin + reset_libs() + c = joinpath(ELFDIR, "libclient.so") + newname = replace("liboracle.so", "oracle" => "Zq7Kp2") + replace_needed!(c, "liboracle.so", newname) + na = read_needed(c) + @test newname in na + @test "libc.so.6" in na # untouched entry intact + @test !("liboracle.so" in na) + @test Set(na) == Set(split(pe("--print-needed", c), "\n")) + reset_libs() + end + + @testset "grow guard rejects a longer replacement" begin + reset_libs() + n = joinpath(ELFDIR, "liboracle.so") + @test_throws "is longer than the existing entry" set_soname!(n, "liboracle_WAYTOOLONG.so") + reset_libs() + end + + @testset "replace_needed! errors when no entry matches" begin + reset_libs() + c = joinpath(ELFDIR, "libclient.so") + @test_throws AssertionError replace_needed!(c, "libdoesnotexist.so", "libx.so") + reset_libs() + end + + @testset "loader resolves substituted names end-to-end" begin + reset_libs() + n = joinpath(ELFDIR, "liboracle.so") + c = joinpath(ELFDIR, "libclient.so") + newname = replace("liboracle.so", "oracle" => "Zq7Kp2") # libZq7Kp2.so + + # privatize: soname + filename + dependent's DT_NEEDED, all same-length + set_soname!(n, newname) + mv(n, joinpath(ELFDIR, newname); force=true) + replace_needed!(c, "liboracle.so", newname) + + h = dlopen(c) + @test 120 == dlsymcall(h, "g1_caller") + @test 212 == dlsymcall(h, "g2_caller") + @test 320 == dlsymcall(h, "g3_caller") + dlclose(h) + + rm(joinpath(ELFDIR, newname); force=true) + reset_libs() + end + + @testset "synthetic: no unsalted libjulia SONAME/NEEDED survives (Linux)" begin + using Patchelf_jll: patchelf + ver = "$(VERSION.major).$(VERSION.minor)" + libdir = abspath(joinpath(Sys.BINDIR, "..", "lib")) + juliadir = joinpath(libdir, "julia") + srccore = joinpath(libdir, "libjulia.so.$ver") + srcint = joinpath(juliadir, "libjulia-internal.so.$ver") + # Only run if this Julia ships the expected libjulia layout. + if isfile(srccore) && isfile(srcint) + tmp = mktempdir() + bundle_julia = joinpath(tmp, "lib", "julia") + mkpath(bundle_julia) + # Copy real core + internal into the bundle (follow_symlinks: srccore/srcint are version symlinks). + core = joinpath(bundle_julia, "libjulia.so.$ver") + internal = joinpath(bundle_julia, "libjulia-internal.so.$ver") + cp(srccore, core; force=true, follow_symlinks=true); chmod(core, 0o755) + cp(srcint, internal; force=true, follow_symlinks=true); chmod(internal, 0o755) + # Fabricate a product .so that NEEDs libjulia (copy internal, which already does) with its own non-libjulia SONAME. + product = joinpath(tmp, "lib", "libsyntheticproduct.so") + cp(srcint, product; force=true, follow_symlinks=true); chmod(product, 0o755) + set_soname!(product, "libsyntheticproduct.so") + + # Drive the real common privatization on a LinuxPlatform. + recipe = JuliaC.BundleRecipe( + link_recipe = JuliaC.LinkRecipe(outname = product), + output_dir = tmp, + libdir = "lib", + privatize = true, + ) + JuliaC.privatize_libjulia_common!(recipe, JuliaC.LinuxPlatform()) + + # After privatization, assert no real (non-symlink) library has a SONAME or DT_NEEDED containing "libjulia". + for (root, _, files) in walkdir(joinpath(tmp, "lib")) + for f in files + p = joinpath(root, f) + islink(p) && continue + occursin(".so", f) || continue + sn = strip(read(`$(patchelf()) --print-soname $p`, String)) + @test !occursin("libjulia", sn) + for nd in split(strip(read(`$(patchelf()) --print-needed $p`, String)), "\n") + @test !occursin("libjulia", nd) + end + end + end + rm(tmp; force=true, recursive=true) + else + @info "skipping synthetic privatization check: libjulia layout not found at $libdir" + end + end + + # Leave the working copies cleaned up; -bak.so are the committed sources. + rm(joinpath(ELFDIR, "liboracle.so"); force=true) + rm(joinpath(ELFDIR, "libclient.so"); force=true) +end diff --git a/test/programatic.jl b/test/programatic.jl index ad8076d..adba7ca 100644 --- a/test/programatic.jl +++ b/test/programatic.jl @@ -53,16 +53,44 @@ julia_dir = joinpath(outdir, "lib", "julia") @test isdir(julia_dir) - dylibs = filter(f -> endswith(f, ".dylib") || endswith(f, ".so"), readdir(julia_dir; join=true)) - salted = filter(f -> occursin("_libjulia", basename(f)), dylibs) - @test !isempty(salted) - for f in salted - if Sys.isapple() + libfiles = filter(f -> endswith(f, ".dylib") || occursin(".so", basename(f)), + readdir(julia_dir; join=true)) + + if Sys.isapple() + # macOS keeps the prepend scheme: a "_libjulia*" sibling exists + # and its install_name still contains "_libjulia". + salted = filter(f -> occursin("_libjulia", basename(f)), libfiles) + @test !isempty(salted) + for f in salted out = read(`otool -D $(f)`, String) - elseif Sys.islinux() - out = read(`$(Patchelf_jll.patchelf()) --print-soname $(f)`, String) + @test occursin("_libjulia", out) + end + elseif Sys.islinux() + # Linux uses equal-length substitution: the leading "libjulia" token of + # each library's basename is replaced by an 8-char salt. The real files + # carry the full version (so.MAJ.MIN.PATCH) while their SONAME carries the + # two-component version (so.MAJ.MIN); the salt substitution is applied to + # both so neither grows. The original unsalted libjulia* files are gone + # and the salted siblings' SONAMEs no longer contain "libjulia". + ver = "$(VERSION.major).$(VERSION.minor)" + reals = filter(f -> !islink(f), libfiles) + # original unsalted internal lib must be removed + @test !any(f -> startswith(basename(f), "libjulia"), reals) + # a salted sibling exists: -internal.so.MAJ.MIN.PATCH where + # is an 8-char token replacing the leading "libjulia" token. + salted = filter(reals) do f + b = basename(f) + m = match(Regex("^([A-Za-z0-9_-]{8})-internal\\.so\\.$(VERSION.major)\\.$(VERSION.minor)\\."), b) + m !== nothing && m.captures[1] != "libjulia" + end + @test !isempty(salted) + for f in salted + soname = readchomp(`$(Patchelf_jll.patchelf()) --print-soname $(f)`) + @test !occursin("libjulia", soname) + # SONAME is the salted two-component form of the salted filename's stem + salt = first(basename(f), 8) + @test soname == "$(salt)-internal.so.$ver" end - @test occursin("_libjulia", out) end dlext = Base.BinaryPlatforms.platform_dlext() diff --git a/test/runtests.jl b/test/runtests.jl index 7fd97c5..dd40a86 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -11,5 +11,6 @@ const TEST_LIB_SRC = joinpath(TEST_LIB_PROJ, "src", "libtest.jl") include("utils.jl") include("programatic.jl") +include("elf_ops.jl") include("cli.jl") include("trimming.jl")