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 = [