Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ac3cb5e
PatchVersion: add in-place .dynstr patch helpers
asinghvi17 May 28, 2026
6ec3bdc
PatchVersion: pure-Julia DT_SONAME/DT_NEEDED ops + unit tests
asinghvi17 May 28, 2026
2580692
privatize: add plat_salted_basename + plat_dep_libs_prepend hooks
asinghvi17 May 28, 2026
05d0a4f
privatize(linux): replace patchelf shell-outs with pure-Julia ELF ops
asinghvi17 May 28, 2026
3a92b0a
test: branch privatization id assertions for macOS prepend vs Linux s…
asinghvi17 May 28, 2026
275aec8
test: synthetic check that no unsalted libjulia name survives Linux p…
asinghvi17 May 28, 2026
83adb0b
Project: demote Patchelf_jll to a test-only dependency
asinghvi17 May 28, 2026
fce83ff
privatize(linux): rename salt helpers (no longer patchelf)
asinghvi17 May 28, 2026
b9d0018
Thread salt through privatization hooks; simplify Linux error handling
asinghvi17 May 28, 2026
c61fb23
Support ELF32 and big-endian in _patch_dyn_string!
asinghvi17 May 28, 2026
d390de3
Condense multi-line comments to single lines
asinghvi17 May 28, 2026
fd47edb
Use open(file; kwargs...) keyword-separator style
asinghvi17 May 28, 2026
c0bf612
Strengthen .dynstr patch guard against tail-merged strings
asinghvi17 May 29, 2026
07e6510
Make in-place .dynstr/version patch errors readable
asinghvi17 May 29, 2026
3bd2d74
privatization: unify name salting behind a single SaltMap
asinghvi17 May 29, 2026
77fe4df
Parameterize SaltMap on its renamer type
asinghvi17 May 29, 2026
77d092a
Fix dependency-ref salting to be length-preserving
asinghvi17 May 29, 2026
dbb2b2e
Condense multi-line comments to single lines
asinghvi17 May 29, 2026
f16cb5c
Tidy elf_ops test comments and a misleading testset name
asinghvi17 May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
.DS_Store
*Manifest*.toml
.vscode
.vscode
test/elf/liboracle.so
test/elf/libclient.so
test/elf/*.o
4 changes: 2 additions & 2 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]
93 changes: 89 additions & 4 deletions src/patchversion.jl
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
112 changes: 91 additions & 21 deletions src/privatize_common.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`, Linux bare `<name>`); 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/<salted base>`, 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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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 <salt>.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
Expand Down
Loading
Loading