diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 4a217a24..fed6aee5 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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 ` to the packed Ruby interpreter. Mutually exclusive with `--chdir-first`. diff --git a/lib/ocran/direction.rb b/lib/ocran/direction.rb index 2b28c98a..b90e8ce6 100644 --- a/lib/ocran/direction.rb +++ b/lib/ocran/direction.rb @@ -447,15 +447,39 @@ 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. @@ -463,7 +487,10 @@ def construct(builder) # 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| @@ -471,6 +498,7 @@ def construct(builder) 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 diff --git a/test/fixtures/portsdll/portsdll.rb b/test/fixtures/portsdll/portsdll.rb new file mode 100644 index 00000000..e862101a --- /dev/null +++ b/test/fixtures/portsdll/portsdll.rb @@ -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 diff --git a/test/test_ocra.rb b/test/test_ocra.rb index 16e983e9..c3f6d8ec 100644 --- a/test/test_ocra.rb +++ b/test/test_ocra.rb @@ -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.