Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
=== Unreleased
- Windows: detected DLLs are now also bundled into bin, next to ruby.exe, whenever they are not already resolvable from where they get packed. The Windows loader resolves a native extension's imports from the extension's own directory, ruby.exe's application directory (bin) plus its ruby_builtin_dlls SxS assembly, and the system directories - PATH is not consulted on hardened systems, and the AddDllDirectory route gems take through ruby_installer/runtime does not exist in a packed app. A DLL loaded from a gem's own tree (e.g. FreeTDS, which tiny_tds ships under ports/), from a devkit's msys64 tree inside the Ruby prefix, or from outside the prefix entirely was packed only at that original location, which the loader never searches, so the packaged application died at require time with a misleading LoadError on machines where a rich PATH did not mask the gap - the out-of-prefix case was skipped entirely by a guard that made its copy_to_bin branch unreachable. DLLs from the Windows directory keep coming from the target system and are never bundled. Companion DLLs found next to native extensions (e.g. libssl-3-x64.dll beside openssl.so in archdir) go into bin as well: a copy in archdir only helps extensions in archdir itself, while the same extension packed at a gem path (openssl and psych are gems since Ruby 3.x) resolves its imports from bin.
- `--cosmo-ruby`: a native gem now also counts as provided by the payload when the payload can resolve the gem's primary feature, not only when the payload has a gemspec of that name. A gemspec is not what makes a library requirable - an extension linked into the APE, or a library in the interpreter's embedded stdlib, answers `require` with nothing under /zip/lib/ruby/gems/*/specifications - so gems such as cgi and pathname (both compiled into CosmoRuby, and both ordinary native gems on Ruby 3.4+) were reported incompatible and refused builds that work. The probe runs the payload once using $LOAD_PATH.resolve_feature_path, which searches exactly as require does, built-in extensions included, but executes none of the code it finds.
- A Rails application with SQLite now packages into a single Actually Portable Executable with `--cosmo-ruby` alone (no compiler at all), given an interpreter with sqlite3, nokogiri, puma, nio4r, bigdecimal and racc linked in: 39.0 MB, serving its first request 1.8s after launch, against 50.4 MB and 1.5s for the same application as a native OCRAN executable, and with nothing unpacked at run time. test/test_rails.rb drives the same HTTP assertions - scaffold CRUD through SQLite with CSRF token and session cookie, dynamically added controllers, persistence across a restart - through both builds. The one remaining limitation is cryptographic and belongs to the interpreter: its openssl is an MbedTLS shim with no cipher, HMAC or PBKDF2 surface, and Rails names those at load time, so the application must fill the gap itself, set SECRET_KEY_BASE, ship no config/credentials.yml.enc, and keep the session out of the (encrypted) cookie. See the Rails section of README.md.
- New `--chdir-exe-dir` option (issue #32): the packaged executable starts the script with its working directory set to the directory containing the executable itself, so relative file access (e.g. reading data files placed next to the .exe) works regardless of how the executable is invoked. Implemented as a new CHDIR_TO_EXE_DIR (0x40) stub header flag; the stub passes `-C <exe dir>` to the packed Ruby interpreter. Mutually exclusive with `--chdir-first`.
Expand Down
38 changes: 33 additions & 5 deletions lib/ocran/direction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -447,30 +447,58 @@ def construct(builder)

# Windows-only: Add detected DLLs
if Gem.win_platform? && @option.auto_detect_dlls?
# The Windows loader resolves the imports of a native extension from
# the extension's own directory, the application directory of the
# packed ruby.exe (bin) plus its SxS assembly (ruby_builtin_dlls),
# and the system directories. PATH is not consulted on hardened
# systems, and the AddDllDirectory mechanism gems use through
# ruby_installer/runtime is not available in a packed app. A detected
# DLL that lives anywhere else - a gem's bundled library (e.g.
# FreeTDS under tiny_tds' ports/), a devkit's msys64 tree inside the
# Ruby prefix, or a directory outside the prefix entirely - is packed
# only at a location the loader never searches, and the application
# dies with LoadError on machines where nothing masks the gap. So
# additionally bundle a copy of every such DLL into bin, next to
# ruby.exe, mirroring what the Linux branch below achieves with
# LD_LIBRARY_PATH. DLLs from the Windows directory always come from
# the target system and are never bundled.
windows_dir = Pathname(ENV["SystemRoot"] || "C:/Windows")
dlls_in_bin = Set.new
add_dll_to_bin = proc do |dll|
next if dll.subpath?(bindir) || !dlls_in_bin.add?(dll.basename.to_s.downcase)

say "Adding detected DLL #{dll} to bin"
builder.copy_to_bin(dll, dll.basename)
end

detect_dlls.each do |dll|
next unless dll.subpath?(exec_prefix) && dll.extname?(".dll") && dll.basename != libruby_so
next unless dll.extname?(".dll") && dll.basename != libruby_so
next if dll.subpath?(windows_dir)

say "Adding detected DLL #{dll}"
if dll.subpath?(exec_prefix)
say "Adding detected DLL #{dll}"
builder.duplicate_to_exec_prefix(dll)
else
builder.copy_to_bin(dll, dll.basename)
end
add_dll_to_bin.call(dll)
end

# Proactively include companion DLLs for loaded native extensions.
# Native extensions (.so) may depend on DLLs in the same archdir
# directory (e.g., libssl-3-x64.dll alongside openssl.so) that are
# loaded lazily on first use. Scanning .so directories ensures those
# DLLs are bundled even when the extension is required but not
# exercised during the OCRAN dependency scan.
# exercised during the OCRAN dependency scan. They also go into bin:
# a copy in archdir only helps extensions in archdir itself, while
# the same extension packed at a gem path (openssl and psych are
# gems since Ruby 3.x) resolves its imports from bin.
features.select { |f| f.extname?(".so") && f.subpath?(exec_prefix) }
.map(&:dirname).uniq
.each do |dir|
dir.each_child do |path|
next unless path.file? && path.extname?(".dll")
say "Adding companion DLL #{path}"
builder.duplicate_to_exec_prefix(path)
add_dll_to_bin.call(path)
end
end
end
Expand Down
16 changes: 16 additions & 0 deletions test/fixtures/portsdll/portsdll.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Loads a DLL the way fat binary gems with bundled libraries do (e.g.
# tiny_tds loading FreeTDS from its ports/ directory): by absolute path
# from a directory the Windows loader does not search when it resolves
# the imports of a native extension at runtime. The test copies a real
# DLL to ports/bin/fakeports.dll before building.
if ENV["OCRAN_EXECUTABLE"]
# Runtime: the DLL must have been bundled next to ruby.exe (bin), the
# only location that resolves in every DLL search mode.
require "rbconfig"
bundled = File.join(RbConfig::CONFIG["bindir"], "fakeports.dll")
exit(File.exist?(bundled) ? 104 : 1)
else
# Dependency run: load the DLL so it shows up as a detected DLL.
require "fiddle"
Fiddle.dlopen(File.expand_path("ports/bin/fakeports.dll", __dir__))
end
26 changes: 26 additions & 0 deletions test/test_ocra.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1502,6 +1502,32 @@ def test_gdbmdll
end
end

# A DLL that the dependency run loads from a directory the Windows
# loader does not search at runtime - the layout of fat binary gems
# with bundled libraries, e.g. tiny_tds loading FreeTDS from its
# ports/ directory - must additionally be bundled into bin next to
# ruby.exe, the only location that resolves in every DLL search mode.
def test_nonsearched_dll_bundled_to_bin
skip "tests Windows DLL bundling" unless Gem.win_platform?

with_fixture 'portsdll' do
dll_source = Dir.glob(File.join(RbConfig::CONFIG['bindir'], '**', '*.dll'))
.reject { |path| File.basename(path) =~ /ruby/i }
.min_by { |path| File.size(path) }
skip "no DLL in bindir to use as fixture" if dll_source.nil?
mkdir_p 'ports/bin'
cp dll_source, 'ports/bin/fakeports.dll'

assert_system("ruby", ocran, "portsdll.rb", *DefaultArgs)
exe = exe_name("portsdll")
assert File.exist?(exe)
pristine_env exe do
system(exe)
assert_equal 104, $?.exitstatus, "fakeports.dll was not bundled next to ruby.exe"
end
end
end

# Test that scripts can require a file relative to the location of
# the script and that such files are correctly added to the
# executable.
Expand Down
Loading