Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

rbthemis: Extra effort to locate Themis Core library #917

Merged
merged 2 commits into from
Apr 21, 2022

Conversation

ilammy
Copy link
Collaborator

@ilammy ilammy commented Apr 20, 2022

Just like with PyThemis, migrate RbThemis to the same pattern of loading Themis Core library dynamically with more attention to versioning.

Detect the platform and load properly versioned libraries first, reducing the chance for an ABI mismatch. However, keep the 'themis' as a fallback (and a default action for Windows).

Ruby's ffi_lib is in some ways more smart than Python's find_library (it supports Apple M1 by accident, for example) but in some ways it's also dumber. Don't depend on ffi's maintainers and hardcode a list of absolute paths to try first in attempt to make things "just work".

Checklist

  • Change is covered by automated tests (kinda?)
  • The coding guidelines are followed
  • Changelog is updated

@ilammy ilammy added W-RbThemis ♦️ Wrapper: RbThemis, Ruby API, Ruby gems O-macOS 💻 Operating system: macOS O-Linux 🐧 Operating system: Linux M1 labels Apr 20, 2022
@ilammy ilammy added this to the 0.15.0 milestone Apr 20, 2022
@ilammy
Copy link
Collaborator Author

ilammy commented Apr 20, 2022

This one is a cousin of #915 for Ruby.

As I've learned, DYLD_LIBRARY_PATH actually does not work on Macs. Since, like, El Capitan it's ignored by dyld because "security!" & "System Integrity Protection!"

Libraries loaded at runtime are expected to follow the rules for libraries loaded on startup. That is, use absolute installation paths. While on Linux you can normally load libraries by their stem name (e.g., libfoo.so), here you're expected to use the library's installation path (you can see it in otool output). Under the hood, Ruby's ffi_lib just hardcodes a bunch of absolute path prefixes and tries them all in sequence if you give it something that is not an absolute path already.

With Python setting DYLD_LIBRARY_PATH in environment "works" only because its ffi has some special compatibility hacks that make it work. I don't like depending on hack, so I would like to rework #915 in the same manner as here. That is, search a bunch of hardcoded absolute paths, in lieu of the interpreter having a "real" native library dependency mechanisms.

Also, the paths searched here on macOS are from Homebrew's "cellar". That is an attempt to make things work if you do

brew unlink libthemis

As this does work for compiled software.

I'm not sure it's a good idea though. It certainly makes releasing things a bit harder, adding one more place with a version to be bumped.

@@ -32,7 +32,54 @@ def empty?(value)

module ThemisImport
extend FFI::Library
ffi_lib 'themis'

THEMIS_CORE_VERSION = '0.14.0'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, so for every release it will be +1 place where we should update version in addition to src/wrappers/themis/ruby/rbthemis.gemspec ? or it should use old version of themis, yes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, so for every release it will be +1 place where we should update version in addition to src/wrappers/themis/ruby/rbthemis.gemspec ?

Correct. That's one more place to update. Which is why I don't particularly like making this change.

But it seems to be the only way to support brew unlink use case. For compiled applications, the linker does this step of "resolve /usr/local/lib/libthemis.dylib symlink and remember the install path from the library metadata, which includes the version".

Keep in mind, this the version of Themis Core formula, not the version of rbthemis gem.

or it should use old version of themis, yes?

If this constant is not updated, most likely the fallback will work selecting some version of Themis Core, which is still likely to work. It will get hairy if the system has multiple versions of Themis Core installed, but normally Homebrew is really against that.

One option, actually, would be to hardcode this path:

/usr/local/opt/libthemis/lib/libthemis.0.dylib

which I think should point to the latest version of libthemis installed in the system.

I guess this will be /opt/homebrew/opt/libthemis/lib/libthemis.0.dylib on M1, but I have no way to check...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep in mind, this the version of Themis Core formula, not the version of rbthemis gem.

hm, I thought that it is gem's version because where homebrew can get version of themis when install ruby gem... Now I don't understand how it works and how it actually installs *(
Can you explain briefly how homebrew install gems on MacOS and where it gets /opt/homebrew/Cellar/libthemis/#{THEMIS_CORE_VERSION}/lib/libthemis.0.dylib this path. Does the binary library have version in the metadata?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/opt/homebrew/opt/libthemis/lib/libthemis.0.dylib looks like you're right and that's the path on M1

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add this file to release checklist so we won't forget to update it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Lagovas, your confusion is exactly what Themis users experience when the docs tell them that in addition to Ruby library they need to install some "core" library as well. And I expect most of them could care less about this split and don't really want to know the intricacies.


So, "gems" are Ruby libraries, installed by gem. They contain Ruby code. rbthemis is the one for RbThemis.

Then, rbthemis needs Themis Core dynamic library to actually function. That one is packaged for macOS via Homebrew, installed with brew. Homebrew does not (normally) install Ruby gems. The libthemis package for Homebrew contains only native libraries for Themis Core.

Then Homebrew installs the following:

  • /opt/homebrew/Cellar/libthemis/${libthemis_version} – this is the "real" prefix of libthemis installation
    • there could be multiple versions installed: .../libthemis/0.14.0, .../libthemis/0.15.0, etc.
    • the version here is the version of Homebrew formula
    • include subdirectory contains C headers
    • lib subdirectory contains libraries:
      • .../lib/libthemis.0.dylib is the "real" library
      • .../lib/libthemis.dylib is a symlink to libthemis.0.dylib
  • /opt/homebrew/opt/libthemis – convenience symlink to ../Cellar/libthemis/${latest_libthemis_version}
    • this symlink is updated every time libthemis is updated and a newer version is installed
    • that's a symlink to the entire directory
  • /opt/homebrew/include/themis/themis.h, ...
  • /opt/homebrew/lib/libthemis.dylib, libthemis.0.dylib, ...
    • when a formula is "linked", Homebrew also installs symlinks to every individual file in "standard" locations

On Intel machines s=/opt/homebrew=/usr/local=g, that's a different Homebrew prefix.

  • The Cellar directory is the "canonical" place where formulas are installed.
  • The opt directory is version-agonstic place.
  • The "standard" directories contain symlinks for the benefit of user.

When you configure your C compiler, you add /opt/homebrew/include to header search paths, /opt/homebrew/lib to library search paths. Then the compiler is able to find any "linked" library installed via Homebrew. (On Intel machines /usr/local/include and /usr/local/lib are usually already in the default search paths.)

This is why Homebrew installs these symlinks normally. It is possible to "unlink" a library if it conflicts with other installation. Homebrew calls this "keg-only" installation. This is usually used to allow the system library to be used by default when building software – but you configure your compiler to look into /opt/homebrew/opt/libthemis/include for applications that build with Themis, and it still works.

Another use case for "keg-only" installations is experimental new versions. For example, we could publish libthemis@2 which you could install in parallel with normal libthemis and experiment with it. Later libthemis@0 could be installable as "keg-only" to allow software built against Themis 0.x to still work, while libthemis could be linked to build new software with it.


So, you configured your compiler. Now, when you write -lthemis in your C flags, the compiler takes themis stem, slaps lib... and ...dylib to it, then looks for libthemis.dylib in the search paths. Normally it finds:

/opt/homebrew/lib/libthemis.dylib

which is a symlink to

/opt/homebrew/lib/libthemis.0.dylib

which is a symlink to

/opt/homebrew/Cellar/libthemis/0.14.0/libthemis.0.dylib

which is the "real" name of the file that gets linked.

When the linker links the executable, it records the "install name" of the library into the executable. This is the canonical absolute path of the library recorded in its metadata. When we compile Themis Core, we set that to the "real" path where Homebrew would install the library.

$ otool -l /usr/local/lib/libthemis.dylib
[...]
Load command 4
          cmd LC_ID_DYLIB
      cmdsize 72
         name /usr/local/opt/libthemis/lib/libthemis.0.dylib (offset 24)
   time stamp 1640176814 Wed Dec 22 21:40:14 2021
      current version 0.0.0
compatibility version 0.0.0
[...]

This is the path which gets recorded in the compiled binary, this is the path which the dynamic library loader will look for when the application is launched.

On Linux the linker usually records only the "stem", the library soname like libthemis.so.0. Then at load time the loader looks for the library in whatever search paths it has. On macOS it's either an absolute path, or certain limited relative paths like @executable_path/... relative the binary which is loaded.


Now enter Ruby (and Python) which don't use "normal" dynamic loader. Instead the load the library dynamically by calling dlopen(). Since there is no prior compilation stage, they don't care for the "install name" of the library.

When you do

ffi_lib 'themis'

it expands the name to libthemis.dylib and then looks for it in a bunch of hardcoded paths:

https://github.com/ffi/ffi/blob/447845cb3030194c79700c86fb388a12e6f81386/lib/ffi/library.rb#L127-L131

whichever loads first – that's your library.

It happens to work for us, but if you want to be more strict – to behave like a compiled application would do – you'd better check the "install name" of the library.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To sum it up, I believe the correct list to try should be

'/usr/local/lib/libthemis.0.dylib',                    # make install puts it there
'/opt/homebrew/opt/libthemis/lib/libthemis.0.dylib',   # try M1 first
'/usr/local/opt/libthemis/lib/libthemis.0.dylib',      # try x86, even on M1 (if running emulated)
'libthemis.0.dylib',                                   # uh... non-Homebrew install?
'themis',                                              # final attempt

That way we'd visit all the usual places where it could be found, and whatever version is installed with make install takes priority.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for such great explanation
but main my question was where we declare version for homebrew)) because i'm not familiar with that and don't know where it gets ${libthemis_version}. You mentioned Formula, so I understood that there should be some manifest file for homebrew and we somehow deploy it into some repository. Went to our buildbot and found that it looks on https://github.com/cossacklabs/homebrew-tap/blob/master/Formula/libthemis.rb#L7.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but main my question was where we declare version for homebrew))

*shrinks in embarrassment*

I understood that there should be some manifest file for homebrew and we somehow deploy it into some repository

You're absolutely right, that's the place which defines the version.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked the path lists. Also tested more thoroughly on Linux with RVM-provided Ruby and decided to just ignore absolute paths on Linux (except for /usr/local override) since only distribution-provided Rubies seem to know about the "correct" paths where distribution package manager would install libthemis.

@@ -32,7 +32,54 @@ def empty?(value)

module ThemisImport
extend FFI::Library
ffi_lib 'themis'

THEMIS_CORE_VERSION = '0.14.0'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add this file to release checklist so we won't forget to update it

Just like with PyThemis, migrate RbThemis to the same pattern of loading
Themis Core library dynamically with more attention to versioning.

Detect the platform and load properly versioned libraries first,
reducing the chance for an ABI mismatch. However, keep the 'themis'
as a fallback (and a default action for Windows).

Ruby's "ffi_lib" is in some ways more smart than Python's find_library
(it supports Apple M1 by accident, for example) but in some ways it's
also dumber. Don't depend on ffi's maintainers and hardcode a list of
absolute paths to try first in attempt to make things "just work".
@ilammy ilammy merged commit 120ec8f into cossacklabs:master Apr 21, 2022
@ilammy ilammy deleted the rbthemis-soname branch April 21, 2022 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
M1 O-Linux 🐧 Operating system: Linux O-macOS 💻 Operating system: macOS W-RbThemis ♦️ Wrapper: RbThemis, Ruby API, Ruby gems
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants