From 3df16924b45adfd88c20ef5fe25b10a1acb82dd7 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 18 Feb 2022 10:59:45 -0500 Subject: [PATCH] [Feature #18249] Implement ABI checking Header file include/ruby/internal/abi.h contains RUBY_ABI_VERSION which is the ABI version. This value should be bumped whenever an ABI incompatible change is introduced. When loading dynamic libraries, Ruby will compare its own `ruby_abi_version` and the `ruby_abi_version` of the loaded library. If these two values don't match it will raise a `LoadError`. This feature can also be turned off by setting the environment variable `RUBY_RUBY_ABI_CHECK=0`. This feature will prevent cases where previously installed native gems fail in unexpected ways due to incompatibility of changes in header files. This will force the developer to recompile their gems to use the same header files as the built Ruby. In Ruby, the ABI version is exposed through `RbConfig::CONFIG["ruby_abi_version"]`. --- dln.c | 17 +++++++++++++ ext/-test-/abi/abi.c | 11 +++++++++ ext/-test-/abi/extconf.rb | 3 +++ include/ruby/internal/abi.h | 45 +++++++++++++++++++++++++++++++++++ include/ruby/ruby.h | 1 + test/-ext-/test_abi.rb | 43 +++++++++++++++++++++++++++++++++ tool/mkconfig.rb | 9 +++++++ tool/transform_mjit_header.rb | 1 + 8 files changed, 130 insertions(+) create mode 100644 ext/-test-/abi/abi.c create mode 100644 ext/-test-/abi/extconf.rb create mode 100644 include/ruby/internal/abi.h create mode 100644 test/-ext-/test_abi.rb diff --git a/dln.c b/dln.c index 3ebce48a45..a38ff7341d 100644 --- a/dln.c +++ b/dln.c @@ -426,12 +426,29 @@ dln_sym(void *handle, const char *symbol) } #endif +#if RUBY_DLN_CHECK_ABI +static bool +abi_check_enabled_p(void) +{ + const char *val = getenv("RUBY_ABI_CHECK"); + return val == NULL || !(val[0] == '0' && val[1] == '\0'); +} +#endif + void * dln_load(const char *file) { #if defined(_WIN32) || defined(USE_DLN_DLOPEN) void *handle = dln_open(file); +#if RUBY_DLN_CHECK_ABI + unsigned long long (*abi_version_fct)(void) = (unsigned long long(*)(void))dln_sym(handle, "ruby_abi_version"); + unsigned long long binary_abi_version = (*abi_version_fct)(); + if (binary_abi_version != ruby_abi_version() && abi_check_enabled_p()) { + dln_loaderror("ABI version of binary is incompatible with this Ruby. Try rebuilding this binary."); + } +#endif + char *init_fct_name; init_funcname(&init_fct_name, file); void (*init_fct)(void) = (void(*)(void))dln_sym(handle, init_fct_name); diff --git a/ext/-test-/abi/abi.c b/ext/-test-/abi/abi.c new file mode 100644 index 0000000000..923e0f67b8 --- /dev/null +++ b/ext/-test-/abi/abi.c @@ -0,0 +1,11 @@ +#include + +unsigned long long +ruby_abi_version(void) +{ + return ULONG_MAX; +} + +void +Init_abi(void) +{} diff --git a/ext/-test-/abi/extconf.rb b/ext/-test-/abi/extconf.rb new file mode 100644 index 0000000000..d786b15db9 --- /dev/null +++ b/ext/-test-/abi/extconf.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: false +require_relative "../auto_ext.rb" +auto_ext(inc: true) diff --git a/include/ruby/internal/abi.h b/include/ruby/internal/abi.h new file mode 100644 index 0000000000..78ed4c1875 --- /dev/null +++ b/include/ruby/internal/abi.h @@ -0,0 +1,45 @@ +#ifndef RUBY_ABI_H +#define RUBY_ABI_H + +/* This number represents Ruby's ABI version. + * + * In development Ruby, it should be bumped every time an ABI incompatible + * change is introduced. This will force other developers to rebuild extension + * gems. + * + * The following cases are considered as ABI incompatible changes: + * - Changing any data structures. + * - Changing macros or inline functions causing a change in behavior. + * - Deprecating or removing function declarations. + * + * The following cases are NOT considered as ABI incompatible changes: + * - Any changes that does not involve the header files in the `include` + * directory. + * - Adding macros, inline functions, or function declarations. + * - Backwards compatible refactors. + * - Editing comments. + * + * In released versions of Ruby, this number should not be changed since teeny + * versions of Ruby should guarantee ABI compatibility. + */ +#define RUBY_ABI_VERSION 0 + +/* Windows does not support weak symbols so ruby_abi_version will not exist + * in the shared library. */ +#if defined(HAVE_FUNC_WEAK) && !defined(_WIN32) && !defined(__MINGW32__) +# define RUBY_DLN_CHECK_ABI 1 +#else +# define RUBY_DLN_CHECK_ABI 0 +#endif + +#if RUBY_DLN_CHECK_ABI + +RUBY_FUNC_EXPORTED unsigned long long __attribute__((weak)) +ruby_abi_version(void) +{ + return RUBY_ABI_VERSION; +} + +#endif + +#endif diff --git a/include/ruby/ruby.h b/include/ruby/ruby.h index f35d13685c..108127a93c 100644 --- a/include/ruby/ruby.h +++ b/include/ruby/ruby.h @@ -23,6 +23,7 @@ #include #include "defines.h" +#include "ruby/internal/abi.h" #include "ruby/internal/anyargs.h" #include "ruby/internal/arithmetic.h" #include "ruby/internal/core.h" diff --git a/test/-ext-/test_abi.rb b/test/-ext-/test_abi.rb new file mode 100644 index 0000000000..ec2050ecad --- /dev/null +++ b/test/-ext-/test_abi.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class TestABI < Test::Unit::TestCase + def test_require_lib_with_incorrect_abi_on_dev_ruby + omit "ABI is not checked" unless abi_checking_supported? + + assert_separately [], <<~RUBY + err = assert_raise(LoadError) { require "-test-/abi" } + assert_match(/ABI version of binary is incompatible with this Ruby/, err.message) + RUBY + end + + def test_disable_abi_check_using_environment_variable + omit "ABI is not checked" unless abi_checking_supported? + + assert_separately [{ "RUBY_ABI_CHECK" => "0" }], <<~RUBY + assert_nothing_raised { require "-test-/abi" } + RUBY + end + + def test_enable_abi_check_using_environment_variable + omit "ABI is not checked" unless abi_checking_supported? + + assert_separately [{ "RUBY_ABI_CHECK" => "1" }], <<~RUBY + err = assert_raise(LoadError) { require "-test-/abi" } + assert_match(/ABI version of binary is incompatible with this Ruby/, err.message) + RUBY + end + + def test_require_lib_with_incorrect_abi_on_release_ruby + omit "ABI is enforced" if abi_checking_supported? + + assert_separately [], <<~RUBY + assert_nothing_raised { require "-test-/abi" } + RUBY + end + + private + + def abi_checking_supported? + !(RUBY_PLATFORM =~ /mswin|mingw/) + end +end diff --git a/tool/mkconfig.rb b/tool/mkconfig.rb index 6e23af5185..0dd25eb400 100755 --- a/tool/mkconfig.rb +++ b/tool/mkconfig.rb @@ -229,6 +229,15 @@ end print " CONFIG[#{v.dump}] = #{(versions[v]||vars[v]).dump}\n" end +# Get the ABI version +File.foreach(File.join(srcdir, "include/ruby/internal/abi.h")) do |l| + m = /^\s*#\s*define\s+RUBY_ABI_VERSION\s+(\d+)/.match(l) + if m + print " CONFIG[\"ruby_abi_version\"] = \"#{m[1]}\"\n" + break + end +end + dest = drive ? %r'= "(?!\$[\(\{])(?i:[a-z]:)' : %r'= "(?!\$[\(\{])' v_disabled = {} v_others.collect! do |x| diff --git a/tool/transform_mjit_header.rb b/tool/transform_mjit_header.rb index 2359ceab7c..8867c556f0 100644 --- a/tool/transform_mjit_header.rb +++ b/tool/transform_mjit_header.rb @@ -41,6 +41,7 @@ module MJITHeader IGNORED_FUNCTIONS = [ 'rb_vm_search_method_slowpath', # This increases the time to compile when inlined. So we use it as external function. 'rb_equal_opt', # Not used from VM and not compilable + 'ruby_abi_version', ] ALWAYS_INLINED_FUNCTIONS = [