diff --git a/.github/workflows/zjit-macos.yml b/.github/workflows/zjit-macos.yml index 1332e25a2c7e1f..9d657a6cd82b29 100644 --- a/.github/workflows/zjit-macos.yml +++ b/.github/workflows/zjit-macos.yml @@ -98,7 +98,7 @@ jobs: rustup install ${{ matrix.rust_version }} --profile minimal rustup default ${{ matrix.rust_version }} - - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + - uses: taiki-e/install-action@6cd13508893c0e7eab5f273c2575d3859bd7229a # v2.86.6 with: tool: nextest@0.9 if: ${{ matrix.test_task == 'zjit-check' }} diff --git a/.github/workflows/zjit-ubuntu.yml b/.github/workflows/zjit-ubuntu.yml index 66f9771e76117b..81d10e9870278d 100644 --- a/.github/workflows/zjit-ubuntu.yml +++ b/.github/workflows/zjit-ubuntu.yml @@ -152,7 +152,7 @@ jobs: ruby-version: '3.1' bundler: none - - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + - uses: taiki-e/install-action@6cd13508893c0e7eab5f273c2575d3859bd7229a # v2.86.6 with: tool: nextest@0.9 if: ${{ matrix.test_task == 'zjit-check' }} diff --git a/NEWS.md b/NEWS.md index 018e0e2c7b448e..4291386323c72a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -171,7 +171,7 @@ They are still available on rubygems.org and can be installed with ### The following default gems are updated. * RubyGems 4.1.0.dev - * 4.0.3 to [v4.0.4][RubyGems-v4.0.4], [v4.0.5][RubyGems-v4.0.5], [v4.0.6][RubyGems-v4.0.6], [v4.0.7][RubyGems-v4.0.7], [v4.0.8][RubyGems-v4.0.8], [v4.0.9][RubyGems-v4.0.9], [v4.0.10][RubyGems-v4.0.10], [v4.0.11][RubyGems-v4.0.11], [v4.0.12][RubyGems-v4.0.12], [v4.0.13][RubyGems-v4.0.13], [v4.0.14][RubyGems-v4.0.14], [v4.0.15][RubyGems-v4.0.15], [v4.0.16][RubyGems-v4.0.16], [v4.0.17][RubyGems-v4.0.17], [v4.0.18][RubyGems-v4.0.18] + * 4.0.3 to [v4.0.4][RubyGems-v4.0.4], [v4.0.5][RubyGems-v4.0.5], [v4.0.6][RubyGems-v4.0.6], [v4.0.7][RubyGems-v4.0.7], [v4.0.8][RubyGems-v4.0.8], [v4.0.9][RubyGems-v4.0.9], [v4.0.10][RubyGems-v4.0.10], [v4.0.11][RubyGems-v4.0.11], [v4.0.12][RubyGems-v4.0.12], [v4.0.13][RubyGems-v4.0.13], [v4.0.14][RubyGems-v4.0.14], [v4.0.15][RubyGems-v4.0.15], [v4.0.16][RubyGems-v4.0.16], [v4.0.17][RubyGems-v4.0.17], [v4.0.18][RubyGems-v4.0.18], [v4.0.19][RubyGems-v4.0.19] * bundler 4.1.0.dev * 4.0.3 to [v4.0.4][bundler-v4.0.4], [v4.0.5][bundler-v4.0.5], [v4.0.6][bundler-v4.0.6], [v4.0.7][bundler-v4.0.7], [v4.0.8][bundler-v4.0.8], [v4.0.9][bundler-v4.0.9], [v4.0.10][bundler-v4.0.10], [v4.0.11][bundler-v4.0.11], [v4.0.12][bundler-v4.0.12], [v4.0.13][bundler-v4.0.13], [v4.0.14][bundler-v4.0.14], [v4.0.15][bundler-v4.0.15], [v4.0.16][bundler-v4.0.16], [v4.0.17][bundler-v4.0.17] * erb 6.0.7 @@ -183,6 +183,8 @@ They are still available on rubygems.org and can be installed with * 1.2.8 to [v1.2.9][ipaddr-v1.2.9] * json 3.0.0.rc1 * 2.18.0 to [v2.18.1][json-v2.18.1], [v2.19.0][json-v2.19.0], [v2.19.1][json-v2.19.1], [v2.19.2][json-v2.19.2], [v2.19.3][json-v2.19.3], [v2.19.4][json-v2.19.4], [v2.19.5][json-v2.19.5], [v2.19.6][json-v2.19.6], [v2.19.7][json-v2.19.7], [v2.19.8][json-v2.19.8], [v2.19.9][json-v2.19.9], [v2.20.0][json-v2.20.0], [v2.21.0][json-v2.21.0], [v2.21.2][json-v2.21.2], [v3.0.0.rc1][json-v3.0.0.rc1] +* net-protocol 0.3.0 + * 0.2.2 to [v0.3.0][net-protocol-v0.3.0] * openssl 4.0.2 * 4.0.0 to [v4.0.1][openssl-v4.0.1], [v4.0.2][openssl-v4.0.2] * pp 0.6.4 @@ -190,7 +192,7 @@ They are still available on rubygems.org and can be installed with * prism 1.9.0 * 1.7.0 to [v1.8.0][prism-v1.8.0], [v1.8.1][prism-v1.8.1], [v1.9.0][prism-v1.9.0] * psych 5.5.0 - * 5.3.1 to [v5.4.0][psych-v5.4.0] + * 5.3.1 to [v5.4.0][psych-v5.4.0], [v5.5.0][psych-v5.5.0] * resolv 0.7.1 * 0.7.0 to [v0.7.1][resolv-v0.7.1] * stringio 3.2.1.dev @@ -205,6 +207,8 @@ They are still available on rubygems.org and can be installed with ### The following bundled gems are updated. * minitest 6.0.6 +* power_assert 3.1.0 + * 3.0.1 to [v3.1.0][power_assert-v3.1.0] * rake 13.4.2 * 13.3.1 to [v13.4.0][rake-v13.4.0], [v13.4.1][rake-v13.4.1], [v13.4.2][rake-v13.4.2] * test-unit 3.7.8 @@ -213,7 +217,7 @@ They are still available on rubygems.org and can be installed with * 0.3.2 to [0.3.3][rss-0.3.3] * net-imap 0.6.6 * 0.6.2 to [v0.6.3][net-imap-v0.6.3], [v0.6.4][net-imap-v0.6.4], [v0.6.4.1][net-imap-v0.6.4.1], [v0.6.5][net-imap-v0.6.5], [v0.6.6][net-imap-v0.6.6] -* rbs 4.0.3 +* rbs 4.2.0 * 3.10.0 to [v3.10.1][rbs-v3.10.1], [v3.10.2][rbs-v3.10.2], [v3.10.3][rbs-v3.10.3], [v3.10.4][rbs-v3.10.4], [v4.0.0.dev.1][rbs-v4.0.0.dev.1], [v4.0.0.dev.2][rbs-v4.0.0.dev.2], [v4.0.0.dev.3][rbs-v4.0.0.dev.3], [v4.0.0.dev.4][rbs-v4.0.0.dev.4], [v4.0.0.dev.5][rbs-v4.0.0.dev.5], [v4.0.0][rbs-v4.0.0], [v4.0.1.dev.1][rbs-v4.0.1.dev.1], [v4.0.1.dev.2][rbs-v4.0.1.dev.2], [v4.0.1][rbs-v4.0.1], [v4.0.2][rbs-v4.0.2], [v4.0.3][rbs-v4.0.3] * typeprof 0.32.0 * mutex_m 0.3.0 @@ -384,6 +388,7 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [RubyGems-v4.0.16]: https://github.com/rubygems/rubygems/releases/tag/v4.0.16 [RubyGems-v4.0.17]: https://github.com/rubygems/rubygems/releases/tag/v4.0.17 [RubyGems-v4.0.18]: https://github.com/rubygems/rubygems/releases/tag/v4.0.18 +[RubyGems-v4.0.19]: https://github.com/rubygems/rubygems/releases/tag/v4.0.19 [bundler-v4.0.4]: https://github.com/rubygems/rubygems/releases/tag/bundler-v4.0.4 [bundler-v4.0.5]: https://github.com/rubygems/rubygems/releases/tag/bundler-v4.0.5 [bundler-v4.0.6]: https://github.com/rubygems/rubygems/releases/tag/bundler-v4.0.6 @@ -424,6 +429,7 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [json-v2.21.0]: https://github.com/ruby/json/releases/tag/v2.21.0 [json-v2.21.2]: https://github.com/ruby/json/releases/tag/v2.21.2 [json-v3.0.0.rc1]: https://github.com/ruby/json/releases/tag/v3.0.0.rc1 +[net-protocol-v0.3.0]: https://github.com/ruby/net-protocol/releases/tag/v0.3.0 [openssl-v4.0.1]: https://github.com/ruby/openssl/releases/tag/v4.0.1 [openssl-v4.0.2]: https://github.com/ruby/openssl/releases/tag/v4.0.2 [pp-v0.6.4]: https://github.com/ruby/pp/releases/tag/v0.6.4 @@ -431,11 +437,13 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [prism-v1.8.1]: https://github.com/ruby/prism/releases/tag/v1.8.1 [prism-v1.9.0]: https://github.com/ruby/prism/releases/tag/v1.9.0 [psych-v5.4.0]: https://github.com/ruby/psych/releases/tag/v5.4.0 +[psych-v5.5.0]: https://github.com/ruby/psych/releases/tag/v5.5.0 [resolv-v0.7.1]: https://github.com/ruby/resolv/releases/tag/v0.7.1 [strscan-v3.1.7]: https://github.com/ruby/strscan/releases/tag/v3.1.7 [strscan-v3.1.8]: https://github.com/ruby/strscan/releases/tag/v3.1.8 [timeout-v0.6.1]: https://github.com/ruby/timeout/releases/tag/v0.6.1 [zlib-v3.2.3]: https://github.com/ruby/zlib/releases/tag/v3.2.3 +[power_assert-v3.1.0]: https://github.com/ruby/power_assert/releases/tag/v3.1.0 [rake-v13.4.0]: https://github.com/ruby/rake/releases/tag/v13.4.0 [rake-v13.4.1]: https://github.com/ruby/rake/releases/tag/v13.4.1 [rake-v13.4.2]: https://github.com/ruby/rake/releases/tag/v13.4.2 diff --git a/ext/openssl/lib/openssl/bn.rb b/ext/openssl/lib/openssl/bn.rb index e4889a140c90b3..3c3299d7de3a22 100644 --- a/ext/openssl/lib/openssl/bn.rb +++ b/ext/openssl/lib/openssl/bn.rb @@ -23,6 +23,14 @@ def pretty_print(q) q.text to_i.to_s } end + + def marshal_dump # :nodoc: + to_i + end + + def marshal_load(integer) # :nodoc: + initialize(integer) + end end # BN end # OpenSSL diff --git a/gc.c b/gc.c index 5101ba902f3e41..793e02b0503d7a 100644 --- a/gc.c +++ b/gc.c @@ -3699,17 +3699,15 @@ rb_gc_obj_optimal_size(VALUE obj) case T_HASH: { - const size_t st_size = sizeof(struct RHash) + sizeof(st_table); - if (RHASH_ST_TABLE_P(obj)) { - return st_size; - } - - const size_t ar_size = sizeof(struct RHash) + offsetof(ar_table, pairs) + RHASH_AR_TABLE_BOUND(obj) * sizeof(ar_table_pair); - if (OBJ_FROZEN(obj) || ar_size > st_size) { - return ar_size; + if (RHASH_AR_TABLE_P(obj)) { + const unsigned bound = RHASH_AR_TABLE_BOUND(obj); + const size_t ar_size = RHASH_AR_SLOT_SIZE(bound); + if (ar_size > RHASH_ST_SLOT_SIZE || OBJ_FROZEN(obj)) { + return ar_size; + } } - return st_size; + return RHASH_ST_SLOT_SIZE; } default: diff --git a/gc/mmtk/Cargo.lock b/gc/mmtk/Cargo.lock index 237a5e130abc19..b68a682f0d4722 100644 --- a/gc/mmtk/Cargo.lock +++ b/gc/mmtk/Cargo.lock @@ -440,8 +440,8 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mmtk" -version = "0.32.0" -source = "git+https://github.com/mmtk/mmtk-core.git?rev=8f806d56469c1c706fd1f88b757c33620bae85ba#8f806d56469c1c706fd1f88b757c33620bae85ba" +version = "0.33.0" +source = "git+https://github.com/mmtk/mmtk-core.git?rev=0fc014f8d05db7fcb6f49ebe20ddd43a3fe2abdf#0fc014f8d05db7fcb6f49ebe20ddd43a3fe2abdf" dependencies = [ "atomic", "atomic-traits", @@ -475,8 +475,8 @@ dependencies = [ [[package]] name = "mmtk-macros" -version = "0.32.0" -source = "git+https://github.com/mmtk/mmtk-core.git?rev=8f806d56469c1c706fd1f88b757c33620bae85ba#8f806d56469c1c706fd1f88b757c33620bae85ba" +version = "0.33.0" +source = "git+https://github.com/mmtk/mmtk-core.git?rev=0fc014f8d05db7fcb6f49ebe20ddd43a3fe2abdf#0fc014f8d05db7fcb6f49ebe20ddd43a3fe2abdf" dependencies = [ "proc-macro-error", "proc-macro2", diff --git a/gc/mmtk/Cargo.toml b/gc/mmtk/Cargo.toml index f274935aca2353..6f2403650dd78a 100644 --- a/gc/mmtk/Cargo.toml +++ b/gc/mmtk/Cargo.toml @@ -25,7 +25,7 @@ features = ["vo_bit", "object_pinning", "sticky_immix_non_moving_nursery"] # Uncomment the following lines to use mmtk-core from the official repository. git = "https://github.com/mmtk/mmtk-core.git" -rev = "8f806d56469c1c706fd1f88b757c33620bae85ba" +rev = "0fc014f8d05db7fcb6f49ebe20ddd43a3fe2abdf" # Uncomment the following line to use mmtk-core from a local repository. # path = "../../../mmtk-core" diff --git a/gc/mmtk/src/object_model.rs b/gc/mmtk/src/object_model.rs index d673ca11a0a31d..6dcc520a051c35 100644 --- a/gc/mmtk/src/object_model.rs +++ b/gc/mmtk/src/object_model.rs @@ -19,6 +19,8 @@ impl VMObjectModel { } impl ObjectModel for VMObjectModel { + const GLOBAL_FIELD_UNLOG_BIT_SPEC: VMGlobalFieldUnlogBitSpec = + VMGlobalFieldUnlogBitSpec::side_first(); const GLOBAL_LOG_BIT_SPEC: VMGlobalLogBitSpec = VMGlobalLogBitSpec::side_first(); // We overwrite the prepended word which were used to hold object sizes. @@ -78,6 +80,14 @@ impl ObjectModel for VMObjectModel { to_obj } + fn try_copy( + from: ObjectReference, + semantics: CopySemantics, + copy_context: &mut GCWorkerCopyContext, + ) -> Option { + Some(Self::copy(from, semantics, copy_context)) + } + fn copy_to(_from: ObjectReference, _to: ObjectReference, _region: Address) -> Address { unimplemented!( "This function cannot be called because we do not support MarkCompact for Ruby." diff --git a/gc/mmtk/src/scanning.rs b/gc/mmtk/src/scanning.rs index 355a2e7759584a..34314ab5c6883e 100644 --- a/gc/mmtk/src/scanning.rs +++ b/gc/mmtk/src/scanning.rs @@ -25,10 +25,10 @@ impl Scanning for VMScanning { false } - fn scan_object>( + fn scan_object( _tls: VMWorkerThread, _object: ObjectReference, - _slot_visitor: &mut EV, + _slot_visitor: &mut impl SlotVisitor, ) { unreachable!("We have not enabled slot enqueuing for any types, yet."); } diff --git a/gems/bundled_gems b/gems/bundled_gems index 48840ad446cdc2..ead76f59bcae20 100644 --- a/gems/bundled_gems +++ b/gems/bundled_gems @@ -7,7 +7,7 @@ # if `revision` is not given, "v"+`version` or `version` will be used. minitest 6.0.6 https://github.com/minitest/minitest -power_assert 3.0.1 https://github.com/ruby/power_assert +power_assert 3.1.0 https://github.com/ruby/power_assert rake 13.4.2 https://github.com/ruby/rake test-unit 3.7.8 https://github.com/test-unit/test-unit rexml 3.4.4 https://github.com/ruby/rexml diff --git a/hash.c b/hash.c index ecc8099e9ee14f..b933cdb1edf48b 100644 --- a/hash.c +++ b/hash.c @@ -65,6 +65,9 @@ * The size of the AR table. * 8-11: RHASH_AR_TABLE_BOUND_MASK * The bounds of the AR table. + * 12: RHASH_COMPARE_BY_IDENTITY + * The hash compares keys by identity (compare_by_identity). + * ST tables also store this in the type of the st_table. * 13-19: RHASH_LEV_MASK * The iterational level of the hash. Used to prevent modifications * to the hash during iteration. @@ -82,6 +85,8 @@ #define COPY_DEFAULT(hash, hash2) copy_default(RHASH(hash), RHASH(hash2)) +#define RHASH_TYPE(hash) (FL_TEST_RAW(hash, RHASH_COMPARE_BY_IDENTITY) ? &identhash : &objhash) + static inline void copy_default(struct RHash *hash, const struct RHash *hash2) { @@ -391,12 +396,12 @@ rb_ident_hash(st_data_t n) } #define identhash rb_hashtype_ident -const struct st_hash_type rb_hashtype_ident = { +static const struct st_hash_type rb_hashtype_ident = { rb_ident_cmp, rb_ident_hash, }; -#define RHASH_IDENTHASH_P(hash) (RHASH_TYPE(hash) == &identhash) +#define RHASH_IDENTHASH_P(hash) FL_TEST_RAW(hash, RHASH_COMPARE_BY_IDENTITY) #define RHASH_STRING_KEY_P(hash, key) (!RHASH_IDENTHASH_P(hash) && (rb_obj_class(key) == rb_cString)) typedef st_index_t st_hash_t; @@ -422,18 +427,6 @@ RHASH_AR_TABLE_MAX_BOUND(VALUE h) #endif } -static inline size_t -ar_table_memsize(size_t capa) -{ - return offsetof(ar_table, pairs) + capa * sizeof(ar_table_pair); -} - -static inline size_t -ar_memsize(size_t capa) -{ - return sizeof(struct RHash) + ar_table_memsize(capa); -} - #define RHASH_AR_TABLE_CONVERTED_TO_ST_TABLE (RHASH_AR_TABLE_MAX_SIZE + 1) #define RHASH_AR_TABLE_MISS RHASH_AR_TABLE_MAX_SIZE @@ -441,8 +434,11 @@ ar_memsize(size_t capa) #define RHASH_AR_CLEARED_HINT 0xff static inline st_hash_t -ar_do_hash(st_data_t key) +ar_do_hash(VALUE hash, st_data_t key) { + if (RHASH_IDENTHASH_P(hash)) { + return (st_hash_t)rb_ident_hash(key); + } return (st_hash_t)rb_any_hash(key); } @@ -537,6 +533,7 @@ hash_verify_(VALUE hash, const char *file, int line) HASH_ASSERT(RHASH_ST_TABLE(hash) != NULL); HASH_ASSERT(RHASH_AR_TABLE_SIZE_RAW(hash) == 0); HASH_ASSERT(RHASH_AR_TABLE_BOUND_RAW(hash) == 0); + HASH_ASSERT(!!RHASH_IDENTHASH_P(hash) == (RHASH_ST_TABLE(hash)->type == &identhash)); } return hash; @@ -556,10 +553,10 @@ RHASH_TABLE_EMPTY_P(VALUE hash) #define RHASH_UNSET_ST_FLAG(h) FL_UNSET_RAW(h, RHASH_ST_TABLE_FLAG) static void -hash_st_table_init(VALUE hash, const struct st_hash_type *type, st_index_t size) +hash_st_table_init(VALUE hash, st_index_t size) { RUBY_ASSERT(rb_gc_obj_slot_size(hash) >= sizeof(struct RHash) + sizeof(st_table)); - st_init_existing_table_with_size(RHASH_ST_TABLE(hash), type, size); + st_init_existing_table_with_size(RHASH_ST_TABLE(hash), RHASH_TYPE(hash), size); RHASH_SET_ST_FLAG(hash); } @@ -629,11 +626,14 @@ RHASH_AR_TABLE_CLEAR(VALUE h) memset(RHASH_AR_TABLE(h), 0, rb_obj_shape_slot_size(h) - sizeof(struct RHash)); } -NOINLINE(static int ar_equal(VALUE x, VALUE y)); +NOINLINE(static int ar_equal(VALUE hash, VALUE x, VALUE y)); static int -ar_equal(VALUE x, VALUE y) +ar_equal(VALUE hash, VALUE x, VALUE y) { + if (RHASH_IDENTHASH_P(hash)) { + return x == y; + } return rb_any_cmp(x, y) == 0; } @@ -684,7 +684,7 @@ ar_find_entry_hint(VALUE hash, ar_hint_t hint, st_data_t key) } RUBY_ASSERT(RHASH_AR_TABLE(hash)->ar_hint.ary[first_match] == hint); - int eq = ar_equal(key, RHASH_AR_TABLE_REF(hash, first_match)->key); + int eq = ar_equal(hash, key, RHASH_AR_TABLE_REF(hash, first_match)->key); if (UNLIKELY(!RHASH_AR_TABLE_P(hash))) { return RHASH_AR_TABLE_CONVERTED_TO_ST_TABLE; } @@ -699,7 +699,7 @@ ar_find_entry_hint(VALUE hash, ar_hint_t hint, st_data_t key) for (unsigned i = first_match + 1; i < RHASH_AR_TABLE_BOUND(hash); i++) { const ar_hint_t *hints = RHASH_AR_TABLE(hash)->ar_hint.ary; if (UNLIKELY(hints[i] == hint)) { - eq = ar_equal(key, RHASH_AR_TABLE_REF(hash, i)->key); + eq = ar_equal(hash, key, RHASH_AR_TABLE_REF(hash, i)->key); if (UNLIKELY(!RHASH_AR_TABLE_P(hash))) { return RHASH_AR_TABLE_CONVERTED_TO_ST_TABLE; } @@ -775,6 +775,7 @@ ar_force_convert_table(VALUE hash, const char *file, int line) ar_table *ar = RHASH_AR_TABLE(hash); st_hash_t hashes[RHASH_AR_TABLE_MAX_SIZE]; unsigned int bound, size; + const struct st_hash_type *type = RHASH_TYPE(hash); RUBY_ASSERT(rb_gc_obj_slot_size(hash) >= sizeof(struct RHash) + sizeof(st_table)); @@ -787,7 +788,7 @@ ar_force_convert_table(VALUE hash, const char *file, int line) for (unsigned int i = 0; i < bound; i++) { // do_hash calls #hash method and it can modify hash object - hashes[i] = UNDEF_P(keys[i]) ? 0 : ar_do_hash(keys[i]); + hashes[i] = UNDEF_P(keys[i]) ? 0 : ar_do_hash(hash, keys[i]); } // check if modified @@ -801,7 +802,7 @@ ar_force_convert_table(VALUE hash, const char *file, int line) // make st st_table tab; st_table *new_tab = &tab; - st_init_existing_table_with_size(new_tab, &objhash, size); + st_init_existing_table_with_size(new_tab, type, size); ar_each_key(ar, bound, ar_each_key_insert, NULL, new_tab, hashes); hash_ar_free_and_clear_table(hash); rb_hash_st_table_set(hash, new_tab); @@ -1027,7 +1028,7 @@ ar_update(VALUE hash, st_data_t key, int retval, existing; unsigned bin = RHASH_AR_TABLE_MISS; st_data_t value = 0, old_key; - st_hash_t hash_value = ar_do_hash(key); + st_hash_t hash_value = ar_do_hash(hash, key); if (UNLIKELY(!RHASH_AR_TABLE_P(hash))) { // `#hash` changes ar_table -> st_table @@ -1116,7 +1117,7 @@ ar_insert_direct(VALUE hash, st_data_t key, st_data_t value, st_hash_t hash_valu static int ar_insert(VALUE hash, st_data_t key, st_data_t value) { - st_hash_t hash_value = ar_do_hash(key); + st_hash_t hash_value = ar_do_hash(hash, key); return ar_insert_direct(hash, key, value, hash_value); } @@ -1127,7 +1128,7 @@ ar_lookup(VALUE hash, st_data_t key, st_data_t *value) return 0; } else { - st_hash_t hash_value = ar_do_hash(key); + st_hash_t hash_value = ar_do_hash(hash, key); if (UNLIKELY(!RHASH_AR_TABLE_P(hash))) { // `#hash` changes ar_table -> st_table return st_lookup(RHASH_ST_TABLE(hash), key, value); @@ -1154,7 +1155,7 @@ static int ar_delete(VALUE hash, st_data_t *key, st_data_t *value) { unsigned bin; - st_hash_t hash_value = ar_do_hash(*key); + st_hash_t hash_value = ar_do_hash(hash, *key); if (UNLIKELY(!RHASH_AR_TABLE_P(hash))) { // `#hash` changes ar_table -> st_table @@ -1245,7 +1246,7 @@ ar_values(VALUE hash, st_data_t *values, st_index_t size) static ar_table* ar_copy(VALUE hash1, VALUE hash2) { - RUBY_ASSERT(rb_gc_obj_slot_size(hash1) >= ar_memsize(RHASH_SIZE(hash2))); + RUBY_ASSERT(rb_gc_obj_slot_size(hash1) >= RHASH_AR_SLOT_SIZE(RHASH_SIZE(hash2))); ar_table *new_tab = RHASH_AR_TABLE(hash1); unsigned int bound = RHASH_AR_TABLE_BOUND(hash2); @@ -1541,19 +1542,16 @@ compact_after_delete(VALUE hash) static inline size_t hash_slot_size(size_t capa, bool frozen) { - const size_t st_size = sizeof(struct RHash) + sizeof(st_table); - if (capa > RHASH_AR_TABLE_MAX_SIZE) { - return st_size; - } - - const size_t ar_size = ar_memsize(capa); - // If the hash is immutable, we can allocate a slot with exactly as much space as needed. - // But if mutable, we must ensure we have enough space to transition to an st_table. - if (frozen || ar_size >= st_size) { - return ar_size; + if (capa <= RHASH_AR_TABLE_MAX_SIZE) { + const size_t ar_size = RHASH_AR_SLOT_SIZE(capa); + // If the hash is immutable, we can allocate a slot with exactly as much space as needed. + // But if mutable, we must ensure we have enough space to transition to an st_table. + if (frozen || ar_size >= RHASH_ST_SLOT_SIZE) { + return ar_size; + } } - return st_size; + return RHASH_ST_SLOT_SIZE; } static VALUE @@ -1576,7 +1574,7 @@ static VALUE hash_init_capa(VALUE hash, size_t size) { if (size > RHASH_AR_TABLE_MAX_SIZE) { - hash_st_table_init(hash, &objhash, size); + hash_st_table_init(hash, size); } else { RUBY_ASSERT(RHASH_AR_TABLE_MAX_BOUND(hash) >= size); @@ -1663,13 +1661,17 @@ hash_copy(VALUE ret, VALUE hash) RHASH_UNSET_ST_FLAG(ret); } - if (rb_hash_compare_by_id_p(hash)) { - // If `hash` is an ar_table it can't be `compare_by_identity?`. - RUBY_ASSERT(RHASH_ST_TABLE_P(hash)); - RHASH_SET_ST_FLAG(ret); + bool compare_by_id = RHASH_IDENTHASH_P(hash); + + if (compare_by_id) { rb_gc_register_pinning_obj(ret); + FL_SET_RAW(ret, RHASH_COMPARE_BY_IDENTITY); + } + else { + FL_UNSET_RAW(ret, RHASH_COMPARE_BY_IDENTITY); } - else if (RHASH_AR_TABLE_MAX_BOUND(ret) < RHASH_SIZE(hash)) { + + if (RHASH_AR_TABLE_MAX_BOUND(ret) < RHASH_SIZE(hash)) { RHASH_SET_ST_FLAG(ret); } @@ -1679,7 +1681,10 @@ hash_copy(VALUE ret, VALUE hash) } else { st_table *tab = RHASH_ST_TABLE(ret); - st_init_existing_table_with_size(tab, &objhash, RHASH_SIZE(hash)); + + st_init_existing_table_with_size(RHASH_ST_TABLE(ret), + compare_by_id ? &identhash : &objhash, + RHASH_SIZE(hash)); int bound = RHASH_AR_TABLE_BOUND(hash); for (int i = 0; i < bound; i++) { @@ -1944,7 +1949,7 @@ rb_hash_init(rb_execution_context_t *ec, VALUE hash, VALUE capa_value, VALUE ifn if (capa_value != INT2FIX(0)) { long capa = NUM2LONG(capa_value); if (capa > 0 && RHASH_AR_TABLE_P(hash) && RHASH_SIZE(hash) == 0 && capa > RHASH_AR_TABLE_MAX_BOUND(hash)) { - hash_st_table_init(hash, &objhash, capa); + hash_st_table_init(hash, capa); } } @@ -2216,6 +2221,9 @@ rb_hash_rehash(VALUE hash) rb_hash_modify_check(hash); if (RHASH_AR_TABLE_P(hash)) { tmp = hash_alloc_capa(0, RHASH_SIZE(hash)); + if (RHASH_IDENTHASH_P(hash)) { + FL_SET_RAW(tmp, RHASH_COMPARE_BY_IDENTITY); + } rb_hash_foreach(hash, rb_hash_rehash_i, (VALUE)tmp); hash_ar_free_and_clear_table(hash); @@ -2224,8 +2232,12 @@ rb_hash_rehash(VALUE hash) else if (RHASH_ST_TABLE_P(hash)) { st_table *old_tab = RHASH_ST_TABLE(hash); tmp = hash_alloc_capa(0, 0); + if (old_tab->type == &identhash) { + FL_SET_RAW(tmp, RHASH_COMPARE_BY_IDENTITY); + } - hash_st_table_init(tmp, old_tab->type, old_tab->num_entries); + hash_st_table_init(tmp, old_tab->num_entries); + RHASH_ST_TABLE(tmp)->type = old_tab->type; tbl = RHASH_ST_TABLE(tmp); rb_hash_foreach(hash, rb_hash_rehash_i, (VALUE)tmp); @@ -4932,10 +4944,17 @@ rb_hash_compare_by_id(VALUE hash) rb_raise(rb_eRuntimeError, "compare_by_identity during iteration"); } - if (RHASH_TABLE_EMPTY_P(hash)) { + if (RHASH_AR_TABLE_P(hash)) { + unsigned int bound = RHASH_AR_TABLE_BOUND(hash); + for (unsigned int i = 0; i < bound; i++) { + if (ar_cleared_entry(hash, i)) continue; + + ar_table_pair *pair = RHASH_AR_TABLE_REF(hash, i); + ar_hint_set(hash, i, (st_hash_t)rb_ident_hash(pair->key)); + } + } + else if (RHASH_TABLE_EMPTY_P(hash)) { // Fast path: There's nothing to rehash, so we don't need a `tmp` table. - // We're most likely an AR table, so this will need an allocation. - ar_force_convert_table(hash, __FILE__, __LINE__); HASH_ASSERT(RHASH_ST_TABLE_P(hash)); RHASH_ST_TABLE(hash)->type = &identhash; @@ -4944,7 +4963,8 @@ rb_hash_compare_by_id(VALUE hash) // Slow path: Need to rehash the members of `self` into a new // `tmp` table using the new `identhash` compare/hash functions. tmp = hash_alloc_capa(0, 0); - hash_st_table_init(tmp, &identhash, RHASH_SIZE(hash)); + FL_SET_RAW(tmp, RHASH_COMPARE_BY_IDENTITY); + hash_st_table_init(tmp, RHASH_SIZE(hash)); identtable = RHASH_ST_TABLE(tmp); rb_hash_foreach(hash, rb_hash_rehash_i, (VALUE)tmp); @@ -4956,6 +4976,8 @@ rb_hash_compare_by_id(VALUE hash) RHASH_ST_CLEAR(tmp); } + FL_SET_RAW(hash, RHASH_COMPARE_BY_IDENTITY); + rb_gc_register_pinning_obj(hash); return hash; @@ -4986,7 +5008,8 @@ VALUE rb_ident_hash_new(void) { VALUE hash = rb_hash_new_capa(0); - hash_st_table_init(hash, &identhash, 0); + FL_SET_RAW(hash, RHASH_COMPARE_BY_IDENTITY); + hash_st_table_init(hash, 0); rb_gc_register_pinning_obj(hash); return hash; } @@ -4995,7 +5018,8 @@ VALUE rb_ident_hash_new_capa(long size) { VALUE hash = rb_hash_new_capa(0); - hash_st_table_init(hash, &identhash, size); + FL_SET_RAW(hash, RHASH_COMPARE_BY_IDENTITY); + hash_st_table_init(hash, size); rb_gc_register_pinning_obj(hash); return hash; } @@ -5343,9 +5367,9 @@ rb_hash_add_new_element(VALUE hash, VALUE key, VALUE val) } static st_data_t -key_stringify(VALUE key) +key_stringify(VALUE hash, VALUE key) { - return (rb_obj_class(key) == rb_cString && !RB_OBJ_FROZEN(key)) ? + return (RHASH_STRING_KEY_P(hash, key) && !RB_OBJ_FROZEN(key)) ? rb_hash_key_str(key) : key; } @@ -5354,7 +5378,7 @@ ar_bulk_insert(VALUE hash, long argc, const VALUE *argv) { long i; for (i = 0; i < argc; ) { - st_data_t k = key_stringify(argv[i++]); + st_data_t k = key_stringify(hash, argv[i++]); st_data_t v = argv[i++]; ar_insert(hash, k, v); RB_OBJ_WRITTEN(hash, Qundef, k); diff --git a/internal/hash.h b/internal/hash.h index f698f9f5707393..ecd23e1c22d6a9 100644 --- a/internal/hash.h +++ b/internal/hash.h @@ -27,6 +27,7 @@ enum ruby_rhash_flags { RHASH_AR_TABLE_SIZE_SHIFT = (FL_USHIFT+4), RHASH_AR_TABLE_BOUND_MASK = (FL_USER8|FL_USER9|FL_USER10|FL_USER11), /* FL 8..11 */ RHASH_AR_TABLE_BOUND_SHIFT = (FL_USHIFT+8), + RHASH_COMPARE_BY_IDENTITY = FL_USER12, /* FL 12 */ // we can not put it in "enum" because it can exceed "int" range. #define RHASH_LEV_MASK (FL_USER13 | FL_USER14 | FL_USER15 | /* FL 13..19 */ \ @@ -198,7 +199,6 @@ RHASH_AR_TABLE_SIZE_RAW(VALUE h) ((unsigned int)((RBASIC(h)->flags >> RHASH_AR_TABLE_BOUND_SHIFT) & \ (RHASH_AR_TABLE_BOUND_MASK >> RHASH_AR_TABLE_BOUND_SHIFT))) -#define RHASH_TYPE(hash) (RHASH_AR_TABLE_P(hash) ? &objhash : RHASH_ST_TABLE(hash)->type) static inline unsigned int RHASH_AR_TABLE_BOUND(VALUE h) @@ -209,4 +209,12 @@ RHASH_AR_TABLE_BOUND(VALUE h) return bound; } +#define RHASH_ST_SLOT_SIZE (sizeof(struct RHash) + sizeof(st_table)) + +static inline size_t +RHASH_AR_SLOT_SIZE(size_t capa) +{ + return sizeof(struct RHash) + offsetof(ar_table, pairs) + capa * sizeof(ar_table_pair); +} + #endif /* INTERNAL_HASH_H */ diff --git a/lib/bundler.rb b/lib/bundler.rb index 4edb2600747d95..fd284e41f61104 100644 --- a/lib/bundler.rb +++ b/lib/bundler.rb @@ -648,7 +648,15 @@ def eval_gemspec(path, contents) # Eval the gemspec from its parent directory, because some gemspecs # depend on "./" relative paths. SharedHelpers.chdir(path.dirname.to_s) do - eval(contents, TOPLEVEL_BINDING.dup, path.expand_path.to_s) + # TOPLEVEL_BINDING always belongs to the main box, so inside a + # Ruby::Box use a binding from the box Bundler is loaded in, where + # Gem::Specification carries Bundler's own monkey patches. + eval_binding = if defined?(Ruby::Box) && Ruby::Box.enabled? + Ruby::Box.current.eval("binding") + else + TOPLEVEL_BINDING.dup + end + eval(contents, eval_binding, path.expand_path.to_s) end end rescue ScriptError, StandardError => e diff --git a/lib/bundler/bundler.gemspec b/lib/bundler/bundler.gemspec index 5bcfaac3b5777e..62ac4ba3331463 100644 --- a/lib/bundler/bundler.gemspec +++ b/lib/bundler/bundler.gemspec @@ -37,14 +37,15 @@ Gem::Specification.new do |s| s.files = Dir.glob("lib/bundler{.rb,/**/*}", File::FNM_DOTMATCH).reject {|f| File.directory?(f) } # Bundler reuses RubyGems' vendored URI, SecureRandom and PubGrub, its - # pure-Ruby YAML serializer and its compact index client. Ship a copy under - # lib/rubygems so Bundler stays self-contained on RubyGems versions that - # predate them. + # pure-Ruby YAML serializer, its compact index client and its credential + # store. Ship a copy under lib/rubygems so Bundler stays self-contained on + # RubyGems versions that predate them. s.files += Dir.glob("lib/rubygems/vendor/uri/**/*", File::FNM_DOTMATCH).reject {|f| File.directory?(f) } s.files += Dir.glob("lib/rubygems/vendor/securerandom/**/*", File::FNM_DOTMATCH).reject {|f| File.directory?(f) } s.files += Dir.glob("lib/rubygems/vendor/pub_grub/**/*", File::FNM_DOTMATCH).reject {|f| File.directory?(f) } s.files += Dir.glob("lib/rubygems/yaml_serializer.rb") s.files += Dir.glob("lib/rubygems/compact_index_client{.rb,/**/*}", File::FNM_DOTMATCH).reject {|f| File.directory?(f) } + s.files += Dir.glob("lib/rubygems/credential_store{.rb,/**/*}", File::FNM_DOTMATCH).reject {|f| File.directory?(f) } # include the gemspec itself because warbler breaks w/o it s.files += %w[lib/bundler/bundler.gemspec] diff --git a/lib/bundler/cli/config.rb b/lib/bundler/cli/config.rb index 976cda748466ce..fc4df9b816cc66 100644 --- a/lib/bundler/cli/config.rb +++ b/lib/bundler/cli/config.rb @@ -97,7 +97,7 @@ def run confirm(name) end - if current_value.nil? + if current_value.nil? && !Bundler.settings.credential_stored?(name) exit 1 else return @@ -111,6 +111,8 @@ def run def confirm_all if @options[:parseable] thor.with_padding do + # --parseable output is fed back to `bundle config set`, where a + # placeholder would be read back as the credential itself. Bundler.settings.all.each do |setting| val = Bundler.settings[setting] Bundler.ui.info "#{setting}=#{val}" @@ -118,7 +120,7 @@ def confirm_all end else Bundler.ui.confirm "Settings are listed in order of priority. The top value will be used.\n" - Bundler.settings.all.each do |setting| + Bundler.settings.all_including_stored_credentials.each do |setting| Bundler.ui.confirm setting show_pretty_values_for(setting) Bundler.ui.confirm "" diff --git a/lib/bundler/env.rb b/lib/bundler/env.rb index 2b297050609887..99df2e490524f0 100644 --- a/lib/bundler/env.rb +++ b/lib/bundler/env.rb @@ -17,9 +17,11 @@ def self.report(options = {}) append_formatted_table("Environment", environment, out) append_formatted_table("Bundler Build Metadata", BuildMetadata.to_h, out) - unless Bundler.settings.all.empty? + settings = Bundler.settings.all_including_stored_credentials + + unless settings.empty? out << "\n## Bundler settings\n\n```\n" - Bundler.settings.all.each do |setting| + settings.each do |setting| out << setting << "\n" Bundler.settings.pretty_values_for(setting).each do |line| out << " " << line << "\n" diff --git a/lib/bundler/installer/parallel_installer.rb b/lib/bundler/installer/parallel_installer.rb index 9a46f408af6cae..1b9badf0226325 100644 --- a/lib/bundler/installer/parallel_installer.rb +++ b/lib/bundler/installer/parallel_installer.rb @@ -20,10 +20,6 @@ def installed? state == :installed end - def enqueued? - state == :enqueued - end - def enqueue_with_priority? state == :installable && spec.extensions.any? end diff --git a/lib/bundler/man/bundle-config.1 b/lib/bundler/man/bundle-config.1 index 0ae7c8b4510af9..03ebb38d721830 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -105,6 +105,16 @@ Cooldown filtering depends on the gem server providing a per\-version \fBcreated .IP A \fBcreated_at\fR timestamp is read as UTC when it carries no time zone offset\. \fBrubygems\.org\fR always sends one, but a third\-party server that omits it would otherwise shift the cooldown window by the offset of whatever machine runs bundler\. .IP "\(bu" 4 +\fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set \fR) in a credential store instead of the plain text config file\. Set it to \fBtrue\fR to use the operating system's native store (macOS Keychain, Linux Secret Service, Windows Credential Manager) when one is available on this platform, or to the name of a backend provided by a third\-party gem, such as \fB1password\fR\. Falls back to the config file when the store is unavailable or fails, warning that the credential was written in plain text\. Defaults to false\. Credentials already written to the config file are not migrated automatically; re\-run \fBbundle config set \fR with the setting enabled to move each one into the store\. Being experimental, the name and behavior of this setting may change in a future release\. +.IP +A credential kept in the store is never printed back\. \fBbundle config get \fR and \fBbundle config list\fR name the key and say that its value lives in the credential store\. With \fB\-\-parseable\fR, such a key is left out entirely, since that output is meant to be read back by \fBbundle config set\fR\. A third\-party backend is not required to enumerate what it holds, so a credential kept in one may not be listed at all\. +.IP +The store belongs to the machine's user, not to a project, so \fB\-\-local\fR and \fB\-\-global\fR make no difference to where a credential is kept\. Setting a host's credential in one project changes it for every project on the machine, and unsetting it there removes it everywhere\. Protecting the store itself is the operating system's job, or that of whichever backend you selected\. +.IP +A credential given in the environment, such as \fBBUNDLE_GEMS__EXAMPLE__COM\fR, takes precedence over the stored one, so a CI run can pass its own credentials without the store getting in the way\. The store takes precedence over the config file\. +.IP +The store can also be selected per host with \fBcredential_store\.\fR (\fBBUNDLE_CREDENTIAL_STORE__\fR), for example \fBbundle config set credential_store\.gems\.example\.com code_artifact\fR\. That host then uses only the named backend, with no chaining to the global one, while every other host keeps following the global setting\. Setting a host's value to \fBfalse\fR keeps that one host on the config file even when a global store is enabled\. +.IP "\(bu" 4 \fBdefault_cli_command\fR (\fBBUNDLE_DEFAULT_CLI_COMMAND\fR): The command that running \fBbundle\fR without arguments should run\. Defaults to \fBcli_help\fR since Bundler 4, but can also be \fBinstall\fR which was the previous default\. .IP "\(bu" 4 \fBdeployment\fR (\fBBUNDLE_DEPLOYMENT\fR): Equivalent to setting \fBfrozen\fR to \fBtrue\fR and \fBpath\fR to \fBvendor/bundle\fR\. diff --git a/lib/bundler/man/bundle-config.1.ronn b/lib/bundler/man/bundle-config.1.ronn index f01f43d709c2c7..d612bdba9a8514 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -179,6 +179,46 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). offset. `rubygems.org` always sends one, but a third-party server that omits it would otherwise shift the cooldown window by the offset of whatever machine runs bundler. +* `credential_store` (`BUNDLE_CREDENTIAL_STORE`): + Experimental: store and read host credentials (the values otherwise set + via `bundle config set `) in a credential store instead + of the plain text config file. Set it to `true` to use the operating + system's native store (macOS Keychain, Linux Secret Service, Windows + Credential Manager) when one is available on this platform, or to the name + of a backend provided by a third-party gem, such as `1password`. Falls + back to the config file when the store is unavailable or fails, warning + that the credential was written in plain text. Defaults to false. + Credentials already written to the config file are not migrated + automatically; re-run `bundle config set ` with the + setting enabled to move each one into the store. Being experimental, the + name and behavior of this setting may change in a future release. + + A credential kept in the store is never printed back. `bundle config get + ` and `bundle config list` name the key and say that its value lives + in the credential store. With `--parseable`, such a key is left out + entirely, since that output is meant to be read back by `bundle config + set`. A third-party backend is not required to enumerate what it holds, so + a credential kept in one may not be listed at all. + + The store belongs to the machine's user, not to a project, so + `--local` and `--global` make no difference to where a credential is + kept. Setting a host's credential in one project changes it for every + project on the machine, and unsetting it there removes it everywhere. + Protecting the store itself is the operating system's job, or that of + whichever backend you selected. + + A credential given in the environment, such as + `BUNDLE_GEMS__EXAMPLE__COM`, takes precedence over the stored one, so a + CI run can pass its own credentials without the store getting in the + way. The store takes precedence over the config file. + + The store can also be selected per host with `credential_store.` + (`BUNDLE_CREDENTIAL_STORE__`), for example `bundle config set + credential_store.gems.example.com code_artifact`. That host then uses + only the named backend, with no chaining to the global one, while every + other host keeps following the global setting. Setting a host's value + to `false` keeps that one host on the config file even when a global + store is enabled. * `default_cli_command` (`BUNDLE_DEFAULT_CLI_COMMAND`): The command that running `bundle` without arguments should run. Defaults to `cli_help` since Bundler 4, but can also be `install` which was the previous diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index c1f8ecf824e1de..4590fc6a53b383 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -62,6 +62,7 @@ class Settings bin cache_path console + credential_store default_cli_command gem.ci gem.github_username @@ -167,6 +168,28 @@ def all keys end + ## + # #all plus the keys whose credential lives in the credential store. Kept + # apart from #all because that one is on the hot path (it is read per gem + # source and per download, and its keys are advertised in the User-Agent), + # while this one is for the commands that display settings. + + def all_including_stored_credentials + keys = stored_credential_keys.map do |key| + key = key.delete_prefix("BUNDLE_") + key.gsub!("___", "-") + key.gsub!("__", ".") + key.downcase! + key + end + + # The listing comes from the globally selected store, but a host can name + # its own, so keep only the keys the per-host lookup agrees are set. + keys.select! {|key| credential_stored?(key) } + + all.union(keys).sort + end + def local_overrides repos = {} all.each do |k| @@ -185,6 +208,9 @@ def mirror_for(uri) end def credentials_for(uri) + stored = credentials_from_store(uri) + return credentials_from_env(uri) || stored if stored + self[uri.to_s] || self[uri.host] end @@ -221,6 +247,12 @@ def pretty_values_for(exposed_key) locations << "Set via #{key}: #{printable_value(value, exposed_key).inspect}" end + if credential_stored?(exposed_key) + # The heading calls this a priority order, but a stored credential sits + # outside it and is used ahead of every config file. + locations << "Set in the credential store, which is used ahead of the config files" + end + if value = @global_config[key] locations << "Set for the current user (#{global_config_file}): #{printable_value(value, exposed_key).inspect}" end @@ -229,6 +261,31 @@ def pretty_values_for(exposed_key) locations end + ## + # True when +name+'s credential lives in the credential store. The secret + # itself is never returned: callers only need to know the setting exists, + # since Settings#[] cannot see past the config files. + + def credential_stored?(name) + raw_key = self.class.key_to_s(name) + return false unless credential_store_key?(raw_key) + return false unless store = active_credential_store(credential_host(raw_key)) + + !store.get(credential_account(raw_key)).nil? + end + + ## + # The keys credentials are stored under, in the same encoding the config + # hashes use, so #all can fold them in. Empty when no store is enabled or + # when the backend cannot enumerate its entries, which is why + # bundle-config(1) warns that a third-party backend may not list. + + def stored_credential_keys + return [] unless store = active_credential_store + + Array(store.list) + end + def processor_count require "etc" Etc.nprocessors @@ -355,7 +412,7 @@ def is_bool(name) def is_string(name) name = self.class.key_to_s(name) - STRING_KEYS.include?(name) || name.start_with?("local.") || name.start_with?("mirror.") || name.start_with?("build.") + STRING_KEYS.include?(name) || name.start_with?("local.") || name.start_with?("mirror.") || name.start_with?("build.") || name.start_with?("credential_store.") end def to_bool(value) @@ -385,6 +442,141 @@ def is_userinfo(value) value.include?(":") end + ## + # The Gem::CredentialStore instance to use, or nil when the + # `credential_store` setting is off. The value is `true`/`"true"` for this + # platform's native backend or a backend name such as `"1password"`. + # Guarded by a cheap lookup so reading and writing settings costs nothing + # extra when the setting is disabled. + + # Kept separate from RubyGems so gem signout does not remove Bundler's + # host credentials. + CREDENTIAL_STORE_SERVICE = "bundler" + + def active_credential_store(host = nil) + spec = credential_store_spec(host) + return nil unless spec + + store_class = credential_store_class + return nil unless store_class + + store_class.for(spec, service: CREDENTIAL_STORE_SERVICE) + end + + # A `credential_store.` setting overrides the global one for that + # host only. There is no chain between backends. + def credential_store_spec(host = nil) + value = self["credential_store.#{host}"] if host + value = self[:credential_store] if value.nil? + + # An environment variable can carry bytes String#downcase would reject. + case value.to_s.b.downcase + when "", "false", "0", "no", "off", "f", "n" then nil + when "true", "1", "yes", "on", "t", "y" then true + else value.to_s + end + end + + def credential_store_class + return @credential_store_class if defined?(@credential_store_class) + + @credential_store_class = + begin + require "rubygems/credential_store" + Gem::CredentialStore if Gem::CredentialStore.respond_to?(:for) + rescue LoadError + nil + end + + if @credential_store_class.nil? + Bundler.ui.warn "The `credential_store` setting is set but this RubyGems does not provide a credential store. Falling back to the Bundler config file." + elsif @credential_store_class.respond_to?(:warn_handler=) + # Bundler replaces Gem.ui with a Gem::SilentUI subclass, which drops + # alert_warning, so every store warning would be lost. + @credential_store_class.warn_handler = ->(message) { Bundler.ui.warn(message) } + end + + @credential_store_class + end + + CREDENTIAL_URL_KEY = %r{\Ahttps?://}i + CREDENTIAL_HOST_KEY = /\A[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?\z/i + + ## + # True for keys that name a host and can therefore hold a credential, + # like the ones set via `bundle config set gems.example.com user:pass`. + # Deliberately a positive test: a key this version does not recognize + # stays in the config file, where Settings#[] can read it back. Matching + # everything not on the known-settings lists would send values such as + # `ssl_client_cert` to the credential store, and they would then read + # back as nil because only #credentials_for consults the store. + + def credential_store_key?(raw_key) + return false if is_bool(raw_key) || is_num(raw_key) || is_array(raw_key) || is_string(raw_key) || is_credential(raw_key) + + CREDENTIAL_URL_KEY.match?(raw_key) || CREDENTIAL_HOST_KEY.match?(raw_key) + end + + def remove_from_store(store, key) + unless store.available? + Bundler.ui.warn "The credential store is enabled but unavailable, so any credential it holds was left in place." + return true + end + + store.delete(key) + end + + # A write clears the plaintext only from the config file it targets, so a + # copy in the other scope comes back into use once the setting is off. + def warn_plaintext_in_other_scope(raw_key, key, hash) + other, other_file = + if hash.equal?(@local_config) + [@global_config, global_config_file] + else + [@local_config, @local_root.join("config")] + end + + return unless other.key?(key) + + # Deliberately not `bundle config unset`, which would clear the store as + # well and throw away the credential this write moved into it. + safe_key = self.class.remove_userinfo(raw_key) + Bundler.ui.warn "The credential for #{safe_key} moved into the credential store, but a plain text copy" \ + " remains in #{other_file}. Delete the #{key_for(safe_key)} entry from that file to finish the move." + end + + def warn_unremoved_credential(raw_key) + Bundler.ui.warn "Could not remove the credential for #{self.class.remove_userinfo(raw_key)} from the credential store." \ + " It is still there. Remove it with your platform's credential manager." + end + + # See Gem::ConfigFile.credential_store_account for why userinfo is dropped. + def credential_account(raw_key) + key_for(self.class.remove_userinfo(raw_key)) + end + + def credential_host(raw_key) + return raw_key unless CREDENTIAL_URL_KEY.match?(raw_key) + + require_relative "vendored_uri" + Gem::URI(raw_key).host || raw_key + rescue Gem::URI::Error + raw_key + end + + # The store stands in for the config file, so it must not override the + # environment, which already overrides that file. Consulted only when the + # store answered, so the layer order without a store is unchanged. + def credentials_from_env(uri) + @env_config[key_for(uri.to_s)] || @env_config[key_for(uri.host)] + end + + def credentials_from_store(uri) + return nil unless store = active_credential_store(uri.host) + + store.get(credential_account(uri.to_s)) || store.get(credential_account(uri.host)) + end + def to_array(value) return [] unless value value.tr(" ", ":").split(":").map(&:to_sym) @@ -398,9 +590,29 @@ def array_to_s(array) def set_key(raw_key, value, hash, file) raw_key = self.class.key_to_s(raw_key) - value = array_to_s(value) if is_array(raw_key) - key = key_for(raw_key) + account = credential_account(raw_key) + + # #temporary passes a nil file, and storing its value would outlive the + # block while its restore pass deleted the real entry. + if file && credential_store_key?(raw_key) && (store = active_credential_store(credential_host(raw_key))) + if value.nil? + warn_unremoved_credential(raw_key) unless remove_from_store(store, account) + elsif value.is_a?(String) && is_userinfo(value) + if store.set(account, value) + value = nil + warn_plaintext_in_other_scope(raw_key, key, hash) + else + warn_unremoved_credential(raw_key) if store.available? && !store.delete(account) + Bundler.ui.warn "Could not write the credential for #{self.class.remove_userinfo(raw_key)} to the credential store," \ + " so it was written to #{file} in plain text." + end + else + warn_unremoved_credential(raw_key) unless remove_from_store(store, account) + end + end + + value = array_to_s(value) if is_array(raw_key) return if hash[key] == value @@ -531,6 +743,20 @@ def self.key_for(key) key.gsub(/\A([ #]*)/, '\1BUNDLE_') end + def self.remove_userinfo(key) + return key unless CREDENTIAL_URL_KEY.match?(key) + + require_relative "vendored_uri" + uri = Gem::URI(key) + return key unless uri.userinfo + + uri = uri.dup + uri.user = uri.password = nil + uri.to_s + rescue Gem::URI::Error + key + end + # TODO: duplicates Rubygems#normalize_uri # TODO: is this the correct place to validate mirror URIs? def self.normalize_uri(uri) diff --git a/lib/bundler/source/git.rb b/lib/bundler/source/git.rb index ff08ef03281fe5..6258308e3b6dbb 100644 --- a/lib/bundler/source/git.rb +++ b/lib/bundler/source/git.rb @@ -30,6 +30,7 @@ def initialize(options) @copied = false @local = false + @cached_app_cache_path = nil end def remote! @@ -91,9 +92,9 @@ def include?(other) def to_s begin - at = humanized_ref || current_branch - - rev = "at #{at}@#{shortref_for_display(revision)}" + at = humanized_ref + at = "#{at}@" if at + rev = "at #{at}#{shortref_for_display(revision)}" rescue GitError "" end @@ -277,6 +278,11 @@ def cache_to(custom_path, try_migrate: false) app_cache_path = app_cache_path(custom_path) + # When several gems share a single git source, this is called once per + # gem. Copying the repository is expensive for large repos, so skip it + # if we already populated this cache during the same command. + return if @cached_app_cache_path == app_cache_path + migrate = try_migrate ? bare_repo?(app_cache_path) : false set_cache_path!(nil) if migrate @@ -288,6 +294,8 @@ def cache_to(custom_path, try_migrate: false) git_proxy.checkout if migrate || requires_checkout? git_proxy.copy_to(app_cache_path, @submodules) serialize_gemspecs_in(app_cache_path) + + @cached_app_cache_path = app_cache_path end def checkout diff --git a/lib/bundler/ui/shell.rb b/lib/bundler/ui/shell.rb index b836208da8e30c..ac37eb5e9fa47a 100644 --- a/lib/bundler/ui/shell.rb +++ b/lib/bundler/ui/shell.rb @@ -76,10 +76,6 @@ def debug? level("debug") end - def quiet? - level("quiet") - end - def ask(msg) @shell.ask(msg, :green) end diff --git a/lib/bundler/ui/silent.rb b/lib/bundler/ui/silent.rb index 83d31d4b5530e6..c81e377d6d3777 100644 --- a/lib/bundler/ui/silent.rb +++ b/lib/bundler/ui/silent.rb @@ -45,10 +45,6 @@ def info? false end - def quiet? - false - end - def warn? false end diff --git a/lib/net/http.rb b/lib/net/http.rb index 4e2e6c9263f1d9..da8f9361f5ee8b 100644 --- a/lib/net/http.rb +++ b/lib/net/http.rb @@ -2497,11 +2497,17 @@ def transport_request(req) # still read the received response. end + informational_count = 0 begin res = HTTPResponse.read_new(@socket) res.decode_content = req.decode_content res.body_encoding = @response_body_encoding res.ignore_eof = @ignore_eof + if res.kind_of?(HTTPInformation) + informational_count += 1 + raise HTTPBadResponse, 'too many informational responses' if + informational_count > HTTPResponse::MAX_INFORMATIONAL_RESPONSES + end end while res.kind_of?(HTTPInformation) res.uri = req.uri diff --git a/lib/net/http/header.rb b/lib/net/http/header.rb index 4459d149695bf0..291cc333819df7 100644 --- a/lib/net/http/header.rb +++ b/lib/net/http/header.rb @@ -631,11 +631,35 @@ def set_range(r, e = nil) # res = Net::HTTP.get_response(hostname, '/todos/1') # res.content_length # => nil # + # The value must consist of digits only. + # Multiple 'Content-Length' values are accepted + # only when they are all identical; + # otherwise Net::HTTPHeaderSyntaxError is raised. + # See {RFC 9110 Section 8.6}[https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6]. def content_length - return nil unless key?('Content-Length') - len = self['Content-Length'].slice(/\d+/) or - raise Net::HTTPHeaderSyntaxError, 'wrong Content-Length format' - len.to_i + values = @header['content-length'] + return nil if values.nil? + + lengths = [] + values.each do |value| + value = value.strip + if value.empty? + raise Net::HTTPHeaderSyntaxError, + "empty Content-Length value" + end + lengths.concat(value.split(/\s*,\s*/, -1)) + end + if lengths.uniq.size != 1 + raise Net::HTTPHeaderSyntaxError, + "Content-Length has multiple different values: " + + values.join(", ") + end + length = lengths.first + unless /\A\d+\z/.match?(length) + raise Net::HTTPHeaderSyntaxError, + "wrong Content-Length format: #{length}" + end + length.to_i end # Sets the value of field 'Content-Length' to the given numeric; diff --git a/lib/net/http/net-http.gemspec b/lib/net/http/net-http.gemspec index d59d5c3b748a22..318f9dc7241013 100644 --- a/lib/net/http/net-http.gemspec +++ b/lib/net/http/net-http.gemspec @@ -35,5 +35,6 @@ Gem::Specification.new do |spec| spec.bindir = "exe" spec.require_paths = ["lib"] + spec.add_dependency "net-protocol", ">= 0.3.0" spec.add_dependency "uri", ">= 0.11.1" end diff --git a/lib/net/http/response.rb b/lib/net/http/response.rb index eec12a290021c6..db719161752e91 100644 --- a/lib/net/http/response.rb +++ b/lib/net/http/response.rb @@ -136,6 +136,10 @@ class Net::HTTPResponse # The maximum total size in bytes of the response header. MAX_RESPONSE_HEADER_LENGTH = 1024 * 1024 # 1 MiB + # The maximum number of informational (1xx) responses accepted before the + # final response. + MAX_INFORMATIONAL_RESPONSES = 100 + class << self # true if the response has a body. def body_permitted? @@ -155,11 +159,17 @@ def read_new(sock) #:nodoc: internal use only res end + def read_line(sock, limit, ignore_eof = false) #:nodoc: internal use only + sock.readuntil("\n", ignore_eof, limit: limit) + rescue Net::ReadLimitExceeded + raise Net::HTTPBadResponse, 'response line too long' + end + private # :stopdoc: def read_status_line(sock) - str = sock.readline + str = read_line(sock, MAX_RESPONSE_HEADER_LENGTH).chop m = /\AHTTP(?:\/(\d+\.\d+))?\s+(\d\d\d)(?:\s+(.*))?\z/in.match(str) or raise Net::HTTPBadResponse, "wrong status line: #{str.dump}" m.captures @@ -175,7 +185,7 @@ def each_response_header(sock) key = value = nil remaining = MAX_RESPONSE_HEADER_LENGTH while true - line = sock.readuntil("\n", true) + line = read_line(sock, MAX_RESPONSE_HEADER_LENGTH, true) remaining -= line.bytesize raise Net::HTTPBadResponse, 'response header too large' if remaining < 0 line = line.sub(/\s+\z/, '') @@ -630,7 +640,7 @@ def read_body_0(dest) def read_chunked(dest, chunk_data_io) # :nodoc: total = 0 while true - line = @socket.readline + line = self.class.read_line(@socket, MAX_RESPONSE_HEADER_LENGTH).chop hexlen = line.slice(/[0-9a-fA-F]+/) or raise Net::HTTPBadResponse, "wrong chunk size line: #{line}" len = hexlen.hex @@ -642,7 +652,7 @@ def read_chunked(dest, chunk_data_io) # :nodoc: @socket.read 2 # \r\n end end - until @socket.readline.empty? + until self.class.read_line(@socket, MAX_RESPONSE_HEADER_LENGTH).chop.empty? # none end end diff --git a/lib/net/protocol.rb b/lib/net/protocol.rb index a4ac3c7c10a388..7e42d226e23ce2 100644 --- a/lib/net/protocol.rb +++ b/lib/net/protocol.rb @@ -26,7 +26,7 @@ module Net # :nodoc: class Protocol #:nodoc: internal use only - VERSION = "0.2.2" + VERSION = "0.3.0" private def Protocol.protocol_param(name, val) diff --git a/lib/rubygems/command.rb b/lib/rubygems/command.rb index d38363f293c79c..6198569597fab7 100644 --- a/lib/rubygems/command.rb +++ b/lib/rubygems/command.rb @@ -440,7 +440,7 @@ def handles?(args) def handle_options(args) args = add_extra_args(args) check_deprecated_options(args) - @options = Marshal.load Marshal.dump @defaults # deep copy + @options = Gem::Util.deep_dup @defaults parser.parse!(args) @options[:args] = args end diff --git a/lib/rubygems/commands/exec_command.rb b/lib/rubygems/commands/exec_command.rb index 1feafbdd358316..6f1ef269fdad25 100644 --- a/lib/rubygems/commands/exec_command.rb +++ b/lib/rubygems/commands/exec_command.rb @@ -79,7 +79,7 @@ def execute def handle_options(args) args = add_extra_args(args) check_deprecated_options(args) - @options = Marshal.load Marshal.dump @defaults # deep copy + @options = Gem::Util.deep_dup @defaults parser.order!(args) do |v| # put the non-option back at the front of the list of arguments args.unshift(v) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 78fb844eb9634d..494525d661af2b 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -17,7 +17,9 @@ def description # :nodoc: The gem can be removed from the index and deleted from the server using the yank command. For further discussion see the help for the yank command. -The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. +The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. If the :credential_store: gemrc option (or RUBYGEMS_CREDENTIAL_STORE environment variable) is set, the API key is stored in and read from the credential store it selects instead of ~/.gem/credentials. + +The API key to send is resolved in this order: the GEM_HOST_API_KEY environment variable, the --key option, the host's own key in the credential store (when :credential_store: is set), the host's own key in ~/.gem/credentials, then the default RubyGems.org key from either place. The first one found is used. EOF end diff --git a/lib/rubygems/commands/signin_command.rb b/lib/rubygems/commands/signin_command.rb index 0f77908c5bfb76..03388447474747 100644 --- a/lib/rubygems/commands/signin_command.rb +++ b/lib/rubygems/commands/signin_command.rb @@ -21,7 +21,9 @@ def description # :nodoc: "The signin command executes host sign in for a push server (the default is"\ " https://rubygems.org). The host can be provided with the host flag or can"\ " be inferred from the provided gem. Host resolution matches the resolution"\ - " strategy for the push command." + " strategy for the push command. If the :credential_store: gemrc option (or"\ + " RUBYGEMS_CREDENTIAL_STORE environment variable) is set, the resulting API key is"\ + " stored in the credential store it selects instead of ~/.gem/credentials." end def usage # :nodoc: diff --git a/lib/rubygems/commands/signout_command.rb b/lib/rubygems/commands/signout_command.rb index bdd01e4393f53c..b65becd89dd7ea 100644 --- a/lib/rubygems/commands/signout_command.rb +++ b/lib/rubygems/commands/signout_command.rb @@ -9,7 +9,10 @@ def initialize def description # :nodoc: "The `signout` command is used to sign out from all current sessions,"\ - " allowing you to sign in using a different set of credentials." + " allowing you to sign in using a different set of credentials. It removes"\ + " the ~/.gem/credentials file. If the :credential_store: gemrc option is"\ + " set, it also removes every RubyGems key from the credential store,"\ + " including keys saved for other hosts with `gem signin --host`." end def usage # :nodoc: @@ -18,14 +21,31 @@ def usage # :nodoc: def execute credentials_path = Gem.configuration.credentials_path + credentials_file_exists = File.exist?(credentials_path) - if !File.exist?(credentials_path) + if !credentials_file_exists && !Gem.configuration.credential_store alert_error "You are not currently signed in." - elsif !File.writable?(credentials_path) - alert_error "File '#{Gem.configuration.credentials_path}' is read-only."\ - " Please make sure it is writable." + return + end + + # An unwritable file must not leave the stored keys behind, so each half + # reports its own outcome. + store_cleared, file_removed = Gem.configuration.unset_api_key! + + unremoved = [] + unremoved << "the credential store" unless store_cleared + unremoved << "'#{credentials_path}'" if credentials_file_exists && !file_removed + + unless unremoved.empty? + alert_error "Could not remove the credentials from #{unremoved.join(" and ")}." \ + " They are still there. Check that the file and its directory are writable," \ + " or remove them yourself to finish signing out." + terminate_interaction 1 + end + + if Gem.configuration.credential_store + say "You have successfully signed out of every registry, including RubyGems.org." else - Gem.configuration.unset_api_key! say "You have successfully signed out from all sessions." end end diff --git a/lib/rubygems/compact_index_client/http_fetcher.rb b/lib/rubygems/compact_index_client/http_fetcher.rb index be63807b7098b5..5ba5ee1147820c 100644 --- a/lib/rubygems/compact_index_client/http_fetcher.rb +++ b/lib/rubygems/compact_index_client/http_fetcher.rb @@ -26,9 +26,7 @@ def call(path, headers = {}) private def fetch(uri, headers, redirects_remaining) - response = @remote_fetcher.request(uri, Gem::Net::HTTP::Get) do |req| - headers.each {|name, value| req[name] = value } - end + response = request(uri, headers) case response when Gem::Net::HTTPSuccess, Gem::Net::HTTPNotModified @@ -40,7 +38,12 @@ def fetch(uri, headers, redirects_remaining) location = response["Location"] raise Gem::RemoteFetcher::FetchError.new("redirecting but no redirect location was given", uri) unless location - fetch(uri + location, headers, redirects_remaining - 1) + redirect = uri + location + if https?(uri) && !https?(redirect) + raise Gem::RemoteFetcher::FetchError.new("redirecting to non-https resource: #{Gem::Uri.redact(redirect)}", uri) + end + + fetch(redirect, headers, redirects_remaining - 1) when Gem::Net::HTTPRangeNotSatisfiable raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) unless headers.key?("Range") @@ -50,5 +53,20 @@ def fetch(uri, headers, redirects_remaining) raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) end end + + # The callers fall back to the Marshal index when a fetch fails, and they + # only recognize a failure that arrives as a FetchError. + def request(uri, headers) + @remote_fetcher.request(uri, Gem::Net::HTTP::Get) do |req| + headers.each {|name, value| req[name] = value } + end + rescue Gem::Timeout::Error, IOError, SocketError, SystemCallError, + *(OpenSSL::SSL::SSLError if Gem::HAVE_OPENSSL) => e + raise Gem::RemoteFetcher::FetchError.new("#{e.class}: #{e}", uri) + end + + def https?(uri) + uri.scheme == "https" + end end end diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index 101e9f89ecb0c3..6e6e180faa727e 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -65,6 +65,14 @@ class Gem::ConfigFile DEFAULT_INSTALL_EXTENSION_IN_LIB = true DEFAULT_GLOBAL_GEM_CACHE = false DEFAULT_USE_PSYCH = false + DEFAULT_CREDENTIAL_STORE = false + + ## + # The account name under which the default RubyGems.org API key is + # stored in the credential store, mirroring the +:rubygems_api_key+ + # symbol used by the plain text credentials file. + + CREDENTIAL_STORE_DEFAULT_ACCOUNT = "rubygems_api_key" ## # For Ruby packagers to set configuration defaults. Set in @@ -196,6 +204,17 @@ class Gem::ConfigFile attr_reader :ssl_client_cert + ## + # == Experimental == + # Store and read push/authentication credentials in a credential store + # instead of the plain text credentials file. +true+ selects the operating + # system's native store (macOS Keychain, Linux Secret Service, Windows + # Credential Manager) when one is available on this platform. A string + # selects a named backend registered by a third-party gem, such as + # +"1password"+. +false+ (the default) keeps using the credentials file. + + attr_accessor :credential_store + ## # Create the config file object. +args+ is the list of arguments # from the command line. @@ -231,9 +250,10 @@ def initialize(args) @ipv4_fallback_enabled = ENV["IPV4_FALLBACK_ENABLED"] == "true" || DEFAULT_IPV4_FALLBACK_ENABLED @global_gem_cache = ENV["RUBYGEMS_GLOBAL_GEM_CACHE"] == "true" || DEFAULT_GLOBAL_GEM_CACHE @use_psych = ENV["RUBYGEMS_USE_PSYCH"] == "true" || DEFAULT_USE_PSYCH + @credential_store = normalize_credential_store(ENV["RUBYGEMS_CREDENTIAL_STORE"], DEFAULT_CREDENTIAL_STORE) - operating_system_config = Marshal.load Marshal.dump(OPERATING_SYSTEM_DEFAULTS) - platform_config = Marshal.load Marshal.dump(PLATFORM_DEFAULTS) + operating_system_config = Gem::Util.deep_dup(OPERATING_SYSTEM_DEFAULTS) + platform_config = Gem::Util.deep_dup(PLATFORM_DEFAULTS) system_config = load_file SYSTEM_WIDE_CONFIG_FILE user_config = load_file config_file_name @@ -253,7 +273,7 @@ def initialize(args) # gemhome and gempath are not working with symbol keys if %w[backtrace bulk_threshold cooldown verbose update_sources cert_expiration_length_days concurrent_downloads install_extension_in_lib ipv4_fallback_enabled - global_gem_cache use_psych sources + global_gem_cache use_psych credential_store sources disable_default_gem_server ssl_verify_mode ssl_ca_cert ssl_client_cert].include?(k) k.to_sym else @@ -273,6 +293,7 @@ def initialize(args) @ipv4_fallback_enabled = @hash[:ipv4_fallback_enabled] if @hash.key? :ipv4_fallback_enabled @global_gem_cache = @hash[:global_gem_cache] if @hash.key? :global_gem_cache @use_psych = @hash[:use_psych] if @hash.key? :use_psych + @credential_store = normalize_credential_store(@hash[:credential_store], @credential_store) if @hash.key? :credential_store @home = @hash[:gemhome] if @hash.key? :gemhome @path = @hash[:gempath] if @hash.key? :gempath @@ -289,7 +310,11 @@ def initialize(args) end ## - # Hash of RubyGems.org and alternate API keys + # Hash of RubyGems.org and alternate API keys, as they appear in the + # credentials file. Keys held in the credential store are not included, so + # this is not the full set of keys a command can authenticate with. Use + # #credential_store_api_key_for or #credential_store_default_api_key to + # reach those. def api_keys load_api_keys unless @api_keys @@ -364,45 +389,147 @@ def load_api_keys def rubygems_api_key load_api_keys unless @rubygems_api_key - @rubygems_api_key + # #load_api_keys only reads the credentials file, which no longer holds the + # key once it is stored. A copy left there after the move is stale. + credential_store_default_api_key || @rubygems_api_key end ## # Sets the RubyGems.org API key to +api_key+ def rubygems_api_key=(api_key) + if credential_store + store = active_credential_store + + if api_key.to_s.empty? + warn_unremoved_credential(CREDENTIAL_STORE_DEFAULT_ACCOUNT) if store&.available? && !store.delete(CREDENTIAL_STORE_DEFAULT_ACCOUNT) + elsif store&.set(CREDENTIAL_STORE_DEFAULT_ACCOUNT, api_key) + remove_api_key_from_file(:rubygems_api_key) + @rubygems_api_key = api_key + return + else + warn_unremoved_credential(CREDENTIAL_STORE_DEFAULT_ACCOUNT) if store&.available? && !store.delete(CREDENTIAL_STORE_DEFAULT_ACCOUNT) + warn_credential_store_fallback + end + end + set_api_key :rubygems_api_key, api_key @rubygems_api_key = api_key end + ## + # Looks up +host+'s own API key from the credential store, when the + # #credential_store setting is on. Only the host-specific account is + # consulted: falling back to the default account here would send the + # RubyGems.org key to whatever host was asked for, ahead of that host's own + # key in the credentials file. #credential_store_default_api_key covers the + # default account, at the precedence the credentials file uses for it. + + def credential_store_api_key_for(host) + return nil if host.nil? || host.to_s.empty? + return nil unless credential_store + return nil unless store = active_credential_store + + store.get(self.class.credential_store_account(host)) + end + + ## + # True when a read for +host+ failed rather than finding nothing. Whether + # the store holds a key for it is unknowable once the read fails, which is + # the point: a caller that would otherwise fall through to a key belonging + # to a different host has to treat "unknown" differently from "absent". + + def credential_store_read_failed_for?(host) + return false unless credential_store + return false unless store = active_credential_store + + # #rubygems_api_key reads the default account on the way to answering, and + # for the default host that is the only failure that can happen. + return true if store.read_failed?(CREDENTIAL_STORE_DEFAULT_ACCOUNT) + return false if host.nil? || host.to_s.empty? + + store.read_failed?(self.class.credential_store_account(host)) + end + + ## + # The default RubyGems.org API key from the credential store, or +nil+. + # This is the stored counterpart of #rubygems_api_key, and belongs at the + # same point in the lookup order. + + def credential_store_default_api_key + return nil unless credential_store + return nil unless store = active_credential_store + + store.get(CREDENTIAL_STORE_DEFAULT_ACCOUNT) + end + ## # Set a specific host's API key to +api_key+ def set_api_key(host, api_key) - check_credentials_permissions + if credential_store && host != :rubygems_api_key + store = active_credential_store + + if api_key.to_s.empty? + delete_stored_key(store, host) + elsif store&.set(self.class.credential_store_account(host), api_key) + remove_api_key_from_file(host) + return + else + delete_stored_key(store, host) + warn_credential_store_fallback + end + end - config = load_file(credentials_path).merge(host => api_key) + check_credentials_permissions - dirname = File.dirname credentials_path - require "fileutils" - FileUtils.mkdir_p(dirname) + config = load_file(credentials_path).merge(self.class.normalize_credentials_key(host) => api_key) - permissions = 0o600 & ~File.umask - File.open(credentials_path, "w", permissions) do |f| - f.write self.class.dump_with_rubygems_yaml(config) - end + write_credentials(config) load_api_keys # reload end ## - # Remove the +~/.gem/credentials+ file to clear all the current sessions. + # Remove the +~/.gem/credentials+ file to clear all the current sessions, + # and every RubyGems key from the credential store when the + # #credential_store setting is on, including keys saved for other hosts + # with gem signin --host. def unset_api_key! - return false unless File.exist?(credentials_path) + store = active_credential_store + store_cleared = + if store.nil? + true + elsif store.available? + store.delete_all + else + # Failing here would make signout exit 1 on every platform without a + # native store, and plain success would claim a removal nobody made. + Gem::CredentialStore.warn_once "The credential store is enabled but unavailable, so any key it holds was left in place." + true + end + + file_removed = + if File.exist?(credentials_path) + # POSIX deletes a read-only file whenever the directory is writable, so + # the marking has to be honored explicitly. + if File.writable?(credentials_path) + begin + File.delete(credentials_path) + true + rescue SystemCallError + false + end + else + false + end + else + false + end - File.delete(credentials_path) + [store_cleared, file_removed] end def load_file(filename) @@ -607,6 +734,33 @@ def self.load_with_rubygems_config_hash(yaml) private + # Built on the same normalized form the credentials file uses, so the two + # never disagree about which host a spelling refers to. Userinfo is dropped + # because the account reaches the backend as a command argument, where any + # other user on the machine can read it. + def self.credential_store_account(host) + host = normalize_credentials_key(host).to_s + return host unless host.match?(%r{\Ahttps?://}i) + + require_relative "vendor/uri/lib/uri" + uri = Gem::URI(host) + return host unless uri.userinfo + + uri = uri.dup + uri.user = uri.password = nil + uri.to_s + rescue Gem::URI::Error + host + end + + # A trailing slash, or the underscore pair standing in for a dot, comes back + # rewritten from #load_file, so a raw host would silently miss. + def self.normalize_credentials_key(host) + return host unless host.is_a?(String) + + deep_transform_config_keys!(host => nil).keys.first + end + def self.deep_transform_config_keys!(config) config.transform_keys! do |k| if k.match?(/\A:(.*)\Z/) @@ -647,6 +801,77 @@ def self.deep_transform_config_keys!(config) config end + def active_credential_store + return nil unless credential_store + + require_relative "credential_store" + Gem::CredentialStore.for(credential_store) + end + + def write_credentials(config) + dirname = File.dirname credentials_path + require "fileutils" + FileUtils.mkdir_p(dirname) + + permissions = 0o600 & ~File.umask + File.open(credentials_path, "w", permissions) do |f| + f.write self.class.dump_with_rubygems_yaml(config) + end + end + + def remove_api_key_from_file(host) + return unless File.exist?(credentials_path) + + unless File.writable?(credentials_path) + alert_warning "The API key moved to the credential store but the plain text copy " \ + "in #{credentials_path} could not be removed. Delete it yourself." + return + end + + key = self.class.normalize_credentials_key(host) + config = load_file(credentials_path) + return unless config.key?(key) + + config.delete(key) + write_credentials(config) + load_api_keys + end + + def delete_stored_key(store, host) + account = self.class.credential_store_account(host) + warn_unremoved_credential(account) if store&.available? && !store.delete(account) + end + + def warn_unremoved_credential(account) + alert_warning "Could not remove the API key for #{account} from the credential store. " \ + "It is still there and will be used instead of the one just set. " \ + "Remove it with your platform's credential manager." + end + + def warn_credential_store_fallback + alert_warning "Could not write the API key to the credential store, so it was written to #{credentials_path} in plain text." + end + + # Anything that reads as a boolean is one, so RUBYGEMS_CREDENTIAL_STORE=0 + # turns the store off rather than naming a backend gem "0". + CREDENTIAL_STORE_OFF = %w[false 0 no off f n].freeze + CREDENTIAL_STORE_ON = %w[true 1 yes on t y].freeze + + def normalize_credential_store(value, default) + # An environment variable can carry bytes String#downcase would reject. + normalized = value.to_s.b.downcase + + if normalized.empty? + default + elsif CREDENTIAL_STORE_OFF.include?(normalized) + false + elsif CREDENTIAL_STORE_ON.include?(normalized) + true + else + value + end + end + def set_config_file_name(args) @config_file_name = ENV["GEMRC"] need_config_file_name = false diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb new file mode 100644 index 00000000000000..c8f77e00efa6bb --- /dev/null +++ b/lib/rubygems/credential_store.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +# Skip reloading when an identical copy (e.g. the one shipped inside the Bundler +# gem) was already required from a different path, to avoid redefinition warnings. +return if defined?(Gem::CredentialStore::SERVICE_NAME) + +## +# Gem::CredentialStore is opt-in storage for authentication secrets (API +# keys, host credentials) in the operating system's native secret store +# instead of a plain text file: +# +# * macOS: Keychain, via the +security+ command line tool. +# * Linux: the Secret Service API (GNOME Keyring, KWallet, ...), via +# +secret-tool+. +# * Windows: Credential Manager, via the +Windows.Security.Credentials.PasswordVault+ +# API from PowerShell. +# +# A third party can add another backend (1Password, pass, HashiCorp Vault, +# ...) by shipping a gem that provides +# rubygems/credential_store/backends/ and calls +# .register_backend from it. Users then select it by name instead of +true+ +# (see .resolve_backend). +# +# Every public method traps all errors and returns +nil+/+false+ instead of +# raising, so that callers can transparently fall back to their existing +# file-based storage when the native store is unavailable or fails (a +# locked keychain over SSH, a headless Linux session without a keyring +# daemon, ...). + +class Gem::CredentialStore + SERVICE_NAME = "rubygems" + + ## + # Returns the store to use for +spec+, or +nil+ when the credential store + # is off. +spec+ is either +true+ (use this platform's native backend) or + # the name of a registered backend such as "1password". +service+ names + # the account namespace within the backend, so RubyGems and Bundler keep + # separate credentials in one native store. The store is memoized per + # +spec+ and +service+ for the life of the process, so the read cache and + # any expensive backend startup are shared across callers. A test may + # install a stand-in via #instance= that is returned here for any enabled + # +spec+, or inject a shared backend via #backend=. + + def self.for(spec, service: SERVICE_NAME) + return nil unless spec + return @override if defined?(@override) && @override + + backend = defined?(@override_backend) && @override_backend ? @override_backend : backend_for(spec) + (@instances ||= {})[[spec, service]] ||= new(backend: backend, service: service) + end + + ## + # The default-backed store for this platform, i.e. for(true). + # Kept for callers and tests that only care about the native backend. + + def self.instance + self.for(true) + end + + ## + # Installs a stand-in store that .for returns for any enabled setting. + # Intended for tests that inject a fake backend. + + def self.instance=(store) + @override = store + end + + ## + # Installs a shared backend that .for wraps for every spec and service. + # Intended for tests that need RubyGems and Bundler credentials to land in + # one backend under their own service names. + + def self.backend=(backend) + @override_backend = backend + end + + ## + # Clears the memoized stores, the injected overrides, and the warned + # messages. Intended for tests only. + + def self.reset! + @override = nil + @override_backend = nil + @instances = nil + @warned = nil + @warn_handler = nil + end + + ## + # Warns once per distinct message. A single flag for every message would + # let an early warning about, say, a misspelled backend name suppress the + # later warning that a secret was written in plain text. + + def self.warn_once(message) + @warned ||= {} + return if @warned.key?(message) + + @warned[message] = true + + if defined?(@warn_handler) && @warn_handler + @warn_handler.call(message) + else + Gem.ui.alert_warning message + end + end + + ## + # Sends warnings to +handler+ (anything responding to #call) instead of + # Gem.ui. Bundler sets this because it replaces Gem.ui with a subclass of + # Gem::SilentUI, which discards alert_warning entirely, so a credential + # store failure would otherwise be silent for the whole bundle command. + + def self.warn_handler=(handler) + @warn_handler = handler + end + + ## + # Registers +backend+ under +name+ so it can be selected with + # credential_store = . A third-party backend gem calls this + # from the file RubyGems loads for that name (see .resolve_backend). + + def self.register_backend(name, backend) + (@backends ||= {})[name.to_s] = backend + end + + BACKEND_NAME = /\A[a-z0-9_-]+\z/ + + ## + # Resolves a registered backend by +name+, requiring + # rubygems/credential_store/backends/ on first use so a + # backend shipped as its own gem loads only when actually selected. + # Returns +nil+ (warning once) when the name is malformed or no gem + # provides it, which makes callers fall back to file storage. The fixed + # require prefix and the restricted name charset keep the setting a piece + # of data, never a path or a command. + + def self.resolve_backend(name) + # The setting can carry bytes Regexp#match? would reject. A name that gets + # past the match is ASCII only, so the require path it builds stays sound. + name = name.to_s.b + unless BACKEND_NAME.match?(name) + warn_once "Ignoring invalid credential store backend name #{name.inspect}." + return nil + end + + return @backends[name] if @backends&.key?(name) + + begin + require "rubygems/credential_store/backends/#{name}" + rescue LoadError + warn_once "Credential store backend #{name.inspect} is not installed. " \ + "Install a gem that provides rubygems/credential_store/backends/#{name}, " \ + "or unset the credential_store setting. Falling back to file storage." + return nil + end + + @backends && @backends[name] + end + + def self.backend_for(spec) + spec == true ? default_backend : resolve_backend(spec) + end + private_class_method :backend_for + + def self.default_backend + if Gem.win_platform? + require_relative "credential_store/native/windows" + WindowsBackend + elsif RUBY_PLATFORM.include?("darwin") + require_relative "credential_store/native/macos" + MacOSBackend + elsif RUBY_PLATFORM.include?("linux") + require_relative "credential_store/native/linux" + LinuxBackend if LinuxBackend.available? + end + end + + ## + # +backend+ is only used by tests to inject a fake backend regardless of + # the platform the test suite happens to run on. +service+ is the account + # namespace this store reads and writes under. + + def initialize(backend: self.class.default_backend, service: SERVICE_NAME) + @backend = backend + @service = service + @cache = {} + end + + ## + # True if a native credential backend is usable on this platform. + + def available? + !@backend.nil? + end + + ## + # Returns the secret stored for +account+, or +nil+ if there is none or + # the backend is unavailable/fails. + + def get(account) + return nil unless @backend + return @cache[account] if @cache.key?(account) + + @cache[account] = @backend.get(@service, account) + rescue StandardError => e + warn_failure(:read, e) + # Retrying means another subprocess and, on some platforms, another + # authorization prompt. #read_failed? keeps this apart from an absent one. + (@failed ||= {})[account] = true + @cache[account] = nil + end + + ## + # True when #get returned +nil+ for +account+ because the backend could not + # answer, rather than because nothing is stored under it. Callers that would + # otherwise fall back to a different credential need the difference: a + # missing entry means "use something else", an unreadable one does not. + + def read_failed?(account) + return false unless defined?(@failed) && @failed + + @failed.key?(account) + end + + ## + # Stores +secret+ for +account+. Returns +true+ on success. + + def set(account, secret) + return false unless @backend + + validate_credential(account, secret) + + if @backend.set(@service, account, secret) + @cache[account] = secret + @failed&.delete(account) + invalidate_list + true + else + false + end + rescue StandardError => e + warn_failure(:write, e) + false + end + + ## + # Removes the secret stored for +account+. Returns +true+ if the entry is + # gone, whether or not it existed beforehand. + + def delete(account) + return false unless @backend + + result = @backend.delete(@service, account) + @cache.delete(account) + @failed&.delete(account) + invalidate_list + result + rescue StandardError => e + warn_failure(:remove, e) + false + end + + ## + # The accounts this store holds, or +nil+ when the backend cannot + # enumerate them. Listing is optional in the backend protocol: the native + # backends implement it, but a third-party backend that only resolves + # credentials on demand has nothing to enumerate. Callers must treat +nil+ + # as "unknown", not as "empty". Secrets are never returned. + + def list + return nil unless @backend.respond_to?(:list) + return @list if defined?(@list) + + @list = @backend.list(@service) + rescue StandardError => e + warn_failure(:list, e) + # Remembered for the same reason #get remembers a failed read. + @list = nil + end + + ## + # Removes every entry this store owns (all accounts under its service). + # Returns +true+ when the store is now clear. Used by +gem signout+ to end + # every session at once, mirroring deletion of the whole credentials file. + + def delete_all + return false unless @backend + + result = @backend.delete_all(@service) + @cache.clear + @failed = nil + invalidate_list + result + rescue StandardError => e + warn_failure(:remove, e) + false + end + + private + + # The listing is memoized, so a write has to drop it. + def invalidate_list + remove_instance_variable(:@list) if defined?(@list) + end + + # The macOS keychain hands non-printable bytes back as hex through the only + # read-back its CLI offers. Applied to every backend so the same value is + # stored, or refused for the same reason, everywhere. + PRINTABLE_ASCII = /\A[\x20-\x7e]*\z/ + + # What happens after a failure depends on the operation, so the warning has + # to say the right thing for each. + OUTCOMES = { + read: "any copy left in the config file will be used instead", + write: "falling back to file storage", + remove: "the credential is still in the store", + list: "stored credentials will not be listed", + }.freeze + + # A newline in an account would start a second command in the macOS batch + # input. #set turns the raise back into a warning and a false. + def validate_credential(account, secret) + raise ArgumentError, "credential secret must be printable ASCII" unless secret.to_s.b.match?(PRINTABLE_ASCII) + raise ArgumentError, "credential account must not contain a newline" if account.to_s.include?("\n") + raise ArgumentError, "credential service must not contain a newline" if @service.to_s.include?("\n") + end + + def warn_failure(operation, error) + self.class.warn_once "Credential store #{operation} failed for #{@service}" \ + " (#{error.class}: #{error.message}); #{OUTCOMES[operation]}." + end +end diff --git a/lib/rubygems/credential_store/native/linux.rb b/lib/rubygems/credential_store/native/linux.rb new file mode 100644 index 00000000000000..c6e4ea87cff81b --- /dev/null +++ b/lib/rubygems/credential_store/native/linux.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "open3" + +class Gem::CredentialStore; end unless defined?(Gem::CredentialStore) + +## +# Stores credentials in the Secret Service API (GNOME Keyring, KWallet, +# ...) via the +secret-tool+ command line tool from libsecret. + +class Gem::CredentialStore::LinuxBackend + # secret-tool prints an item's attributes to stderr, one per line. + ACCOUNT_ATTRIBUTE = /^attribute\.account = (.*)$/ + def self.available? + return @available if defined?(@available) + + @available = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir| + File.executable?(File.join(dir, "secret-tool")) + end + end + + ## + # Clears the memoized #available? result. Intended for tests only. + + def self.reset! + remove_instance_variable(:@available) if defined?(@available) + end + + def self.get(service, account) + out, err, status = Open3.capture3( + "secret-tool", "lookup", "service", service, "account", account + ) + # secret-tool exits 1 with nothing on stderr when the entry is simply + # absent. Anything else is a real failure. + unless status.success? + return nil if status.exitstatus == 1 && err.to_s.strip.empty? + + raise "secret-tool exited with #{status.exitstatus}: #{err.strip}" + end + + secret = out.chomp + secret.empty? ? nil : secret + end + + def self.set(service, account, secret) + _out, status = Open3.capture2( + "secret-tool", "store", "--label=RubyGems", "service", service, "account", account, + stdin_data: secret + ) + status.success? + end + + # secret-tool writes attributes to stderr and secrets to stdout, so accounts + # are read from stderr. Discarding stdout also keeps a secret containing a + # newline from being mistaken for an attribute line. + def self.list(service) + _out, err, status = Open3.capture3( + "secret-tool", "search", "--all", "service", service + ) + return [] unless status.success? + + err.scan(ACCOUNT_ATTRIBUTE).flatten.uniq + end + + def self.delete(service, account) + _out, err, status = Open3.capture3( + "secret-tool", "clear", "service", service, "account", account + ) + return cleared?(service, account) if status.success? + + # secret-tool clear exits 1 with no stderr when nothing matched. + status.exitstatus == 1 && err.to_s.strip.empty? + end + + def self.delete_all(service) + _out, err, status = Open3.capture3( + "secret-tool", "clear", "service", service + ) + return cleared?(service) if status.success? + + status.exitstatus == 1 && err.to_s.strip.empty? + end + + # libsecret clears only unlocked items and reports no error for the ones it + # skipped, so a locked keyring answers a clear with success while keeping + # every secret. search exits zero either way, so its output is the answer. + def self.cleared?(service, account = nil) + _out, err, status = Open3.capture3( + "secret-tool", "search", "--all", "service", service + ) + return false unless status.success? + + remaining = err.scan(ACCOUNT_ATTRIBUTE).flatten + account ? !remaining.include?(account) : remaining.empty? + end + private_class_method :cleared? +end diff --git a/lib/rubygems/credential_store/native/macos.rb b/lib/rubygems/credential_store/native/macos.rb new file mode 100644 index 00000000000000..f98bb13ae7ea23 --- /dev/null +++ b/lib/rubygems/credential_store/native/macos.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "open3" + +class Gem::CredentialStore; end unless defined?(Gem::CredentialStore) + +## +# Stores credentials in the macOS Keychain via the +security+ command line +# tool. +security+ has no way to read a password from stdin as raw bytes +# for +add-generic-password+, so #set uses +security -i+ (batch/interactive +# mode, one tokenized command per stdin line) to keep the secret off argv +# and out of +ps+ output. +# +# A newline would start a second command in the +security -i+ batch, and +# +security find-generic-password -w+ prints any non-printable byte back as a +# hex string rather than the original value, so a non-ASCII secret would +# round-trip corrupted. Those are the limits Gem::CredentialStore#set enforces +# for every backend, so the caller falls back to file storage rather than +# storing something that cannot be read back. + +class Gem::CredentialStore::MacOSBackend + NOT_FOUND_STATUS = 44 + + def self.get(service, account) + out, err, status = Open3.capture3( + "security", "find-generic-password", "-a", account, "-s", service, "-w" + ) + # A locked keychain and an absent entry both yield no secret, but only the + # second one is ordinary. + unless status.success? + return nil if status.exitstatus == NOT_FOUND_STATUS + + raise "security exited with #{status.exitstatus}: #{err.strip}" + end + + secret = out.chomp + secret.empty? ? nil : secret + end + + def self.set(service, account, secret) + command = "add-generic-password -U -a #{quote(account)} -s #{quote(service)} -w #{quote(secret)}\n" + _out, err, status = Open3.capture3("security", "-i", stdin_data: command) + return true if status.success? + + # Raise rather than return false so the reason reaches the user. The + # wrapper turns it back into false after reporting it. + raise "security exited with #{status.exitstatus}: #{err.strip}" + end + + # security has no "list by service" subcommand, so this reads the dump, which + # never reports the secrets. + def self.list(service) + out, status = Open3.capture2("security", "dump-keychain", err: File::NULL) + return [] unless status.success? + + out.split(/^keychain: /).filter_map do |entry| + next unless entry[/^\s*"svce"="(.*)"$/, 1] == service + + entry[/^\s*"acct"="(.*)"$/, 1] + end.uniq + end + + def self.delete(service, account) + _out, status = Open3.capture2( + "security", "delete-generic-password", "-a", account, "-s", service, + err: File::NULL + ) + status.success? || status.exitstatus == NOT_FOUND_STATUS + end + + # security deletes one entry per call, so keep going until it reports there + # is nothing left (exit 44). Other services are untouched. + def self.delete_all(service) + loop do + _out, status = Open3.capture2( + "security", "delete-generic-password", "-s", service, + err: File::NULL + ) + return true if status.exitstatus == NOT_FOUND_STATUS + return false unless status.success? + end + end + + def self.quote(value) + %("#{value.gsub("\\", "\\\\\\\\").gsub('"', '\\"')}") + end + private_class_method :quote +end diff --git a/lib/rubygems/credential_store/native/windows.rb b/lib/rubygems/credential_store/native/windows.rb new file mode 100644 index 00000000000000..4a0052939c891d --- /dev/null +++ b/lib/rubygems/credential_store/native/windows.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "open3" + +class Gem::CredentialStore; end unless defined?(Gem::CredentialStore) + +## +# Stores credentials in the Windows Credential Manager via the +# +Windows.Security.Credentials.PasswordVault+ WinRT API, driven from +# PowerShell. Account/service/secret values are passed as environment +# variables rather than interpolated into the script text, so no quoting +# scheme is needed and values cannot break out of the script. +# +# Windows PowerShell is used rather than PowerShell 7 (+pwsh+) because the +# WinRT projection used here is not reliably available under pwsh. It is +# spawned as +powershell+ rather than +powershell.exe+, the way this codebase +# spawns +git+, so PATHEXT resolves it. That also lets the tests put a shim +# ahead of it on Windows, where a file with a shebang is not executable. + +class Gem::CredentialStore::WindowsBackend + LOAD_VAULT_TYPE = <<~POWERSHELL + $ErrorActionPreference = 'Stop' + [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] + POWERSHELL + private_constant :LOAD_VAULT_TYPE + + def self.get(service, account) + script = <<~POWERSHELL + #{LOAD_VAULT_TYPE} + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT) + # Emitting into the pipeline would send the string through PowerShell's + # formatter, which wraps at the host width and would corrupt a secret. + [Console]::Out.Write($credential.Password) + POWERSHELL + + out, err, status = run(script, service, account) + # An absent credential is ordinary. Any other failure is not, so raise and + # let the wrapper report why rather than authenticating without one. + unless status.success? + return nil if missing_credential?(err) + + raise "powershell exited with #{status.exitstatus}: #{err.strip}" + end + + secret = out.chomp + secret.empty? ? nil : secret + end + + def self.set(service, account, secret) + script = <<~POWERSHELL + #{LOAD_VAULT_TYPE} + $vault = New-Object Windows.Security.Credentials.PasswordVault + try { + $existing = $vault.Retrieve($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT) + $vault.Remove($existing) + } catch {} + $credential = New-Object Windows.Security.Credentials.PasswordCredential($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT, $env:RUBYGEMS_CRED_SECRET) + $vault.Add($credential) + POWERSHELL + + _out, err, status = run(script, service, account, secret) + return true if status.success? + + # Raise so the reason reaches the user; see MacOSBackend.set. + raise "powershell exited with #{status.exitstatus}: #{err.strip}" + end + + def self.delete(service, account) + script = <<~POWERSHELL + #{LOAD_VAULT_TYPE} + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT) + $vault.Remove($credential) + POWERSHELL + + _out, err, status = run(script, service, account) + status.success? || missing_credential?(err) + end + + # FindAllByResource raises when the resource has no entries, which is an + # empty list rather than an error. + def self.list(service) + script = <<~POWERSHELL + #{LOAD_VAULT_TYPE} + $vault = New-Object Windows.Security.Credentials.PasswordVault + try { + $vault.FindAllByResource($env:RUBYGEMS_CRED_SERVICE) | ForEach-Object { + [Console]::Out.WriteLine($_.UserName) + } + } catch { + if (-not ($_.Exception.Message -match 'not found|0x80070490')) { throw } + } + POWERSHELL + + out, _err, status = run(script, service, nil) + return [] unless status.success? + + out.split("\n").map(&:chomp).reject(&:empty?).uniq + end + + def self.delete_all(service) + script = <<~POWERSHELL + #{LOAD_VAULT_TYPE} + $vault = New-Object Windows.Security.Credentials.PasswordVault + try { + $vault.FindAllByResource($env:RUBYGEMS_CRED_SERVICE) | ForEach-Object { $vault.Remove($_) } + } catch { + if (-not ($_.Exception.Message -match 'not found|0x80070490')) { throw } + } + POWERSHELL + + _out, err, status = run(script, service, nil) + status.success? || missing_credential?(err) + end + + def self.run(script, service, account, secret = nil) + env = { "RUBYGEMS_CRED_SERVICE" => service, "RUBYGEMS_CRED_ACCOUNT" => account } + env["RUBYGEMS_CRED_SECRET"] = secret if secret + + Open3.capture3(env, "powershell", "-NoProfile", "-NonInteractive", "-Command", "-", stdin_data: script) + end + private_class_method :run + + def self.missing_credential?(message) + text = message.to_s.downcase + text.include?("element not found") || text.include?("0x80070490") || text.include?("could not be found") + end + private_class_method :missing_credential? +end diff --git a/lib/rubygems/ext/builder.rb b/lib/rubygems/ext/builder.rb index f5040d98f29e7e..f1fce48823a888 100644 --- a/lib/rubygems/ext/builder.rb +++ b/lib/rubygems/ext/builder.rb @@ -101,7 +101,9 @@ def self.run(command, results, command_name = nil, dir = Dir.pwd, env = {}) require "open3" # Set $SOURCE_DATE_EPOCH for the subprocess. - build_env = { "SOURCE_DATE_EPOCH" => Gem.source_date_epoch_string }.merge(env) + # Under Ruby::Box mkmf makes RbConfig.expand recurse until SystemStackError. + # Drop $RUBY_BOX last so no caller can restore it. + build_env = { "SOURCE_DATE_EPOCH" => Gem.source_date_epoch_string }.merge(env).merge("RUBY_BOX" => nil) # A single-element command would be parsed as a shell command line, # splitting an unquoted command path containing spaces. Use the # [cmdname, argv0] form to keep exec semantics. diff --git a/lib/rubygems/gemcutter_utilities.rb b/lib/rubygems/gemcutter_utilities.rb index 9c22c14fad5b35..12c8943a58031c 100644 --- a/lib/rubygems/gemcutter_utilities.rb +++ b/lib/rubygems/gemcutter_utilities.rb @@ -48,10 +48,23 @@ def api_key ENV["GEM_HOST_API_KEY"] elsif options[:key] verify_api_key options[:key] + elsif credential_store_key = Gem.configuration.credential_store_api_key_for(host) + credential_store_key elsif Gem.configuration.api_keys.key?(host) Gem.configuration.api_keys[host] else - Gem.configuration.rubygems_api_key + key = Gem.configuration.rubygems_api_key + + # Once the store has refused to answer, this last resort would hand the + # RubyGems.org key to a host that has one of its own, or come away with + # nothing and let the caller ask for a password. + if !@recognizing_session && Gem.configuration.credential_store_read_failed_for?(host) && (key.nil? || !default_host?) + alert_error "The credential store could not be read, so no API key for #{host} could be found. " \ + "Make the store readable and run the command again." + terminate_interaction ERROR_CODE + end + + key end end @@ -155,7 +168,17 @@ def update_scope(scope) def sign_in(sign_in_host = nil, scope: nil) sign_in_host ||= host pretty_host = pretty_host(sign_in_host) - if api_key + # Stopping because the store cannot be read would close the one command + # that can re-authenticate. A flag rather than an argument keeps #api_key + # callable with no arguments, as command plugins that override it define it. + @recognizing_session = true + signed_in = begin + api_key + ensure + @recognizing_session = false + end + + if signed_in say "You are already signed in on #{pretty_host}." return end @@ -196,12 +219,31 @@ def sign_in(sign_in_host = nil, scope: nil) def verify_api_key(key) if Gem.configuration.api_keys.key? key Gem.configuration.api_keys[key] + elsif stored_key = stored_api_key_named(key) + stored_key else alert_error "No such API key. Please add it to your configuration (done automatically on initial `gem push`)." terminate_interaction(ERROR_CODE) end end + ## + # The default key, when +name+ is the name the credentials file knows it by. + # That file renames :rubygems_api_key to :rubygems on the way in, so the name + # survives only there, and moving the key into the store would otherwise put + # it out of reach of --key. + # + # Only that one name. The store is keyed by host, and --key names a key, so + # looking any other name up there would let --key reach a host's key and send + # it somewhere else. The credentials file keeps the two apart by type, since + # --key arrives as a Symbol and host entries are strings. + + def stored_api_key_named(name) + return nil unless name.to_s == "rubygems" + + Gem.configuration.credential_store_default_api_key + end + ## # If +response+ is an HTTP Success (2XX) response, yields the response if a # block was given or shows the response body to the user. diff --git a/lib/rubygems/util.rb b/lib/rubygems/util.rb index 2d66f3a2da3f9e..cf2305304bad34 100644 --- a/lib/rubygems/util.rb +++ b/lib/rubygems/util.rb @@ -91,6 +91,26 @@ def self.glob_files_in_dir(glob, base_path) end end + ## + # Duplicates +obj+ without Marshal, which cannot resolve Gem:: constants from + # the root box under Ruby::Box (RUBY_BOX=1). Hashes and arrays are copied + # recursively, anything else with a plain +dup+, so an object's internals stay + # shared with the original, as are hash keys. Shared references are not + # preserved, and a cyclic +obj+ raises SystemStackError. + + def self.deep_dup(obj) # :nodoc: + case obj + when Hash then obj.to_h {|k, v| [k, deep_dup(v)] } + when Array then obj.map {|e| deep_dup(e) } + else + begin + obj.dup + rescue TypeError + obj + end + end + end + ## # Corrects +path+ (usually returned by `Gem::URI.parse().path` on Windows), that # comes with a leading slash. diff --git a/parse.y b/parse.y index f55b6374fef5d8..c726402e0fbd3c 100644 --- a/parse.y +++ b/parse.y @@ -6607,13 +6607,11 @@ assocs : assoc assocs = tail; } else if (tail) { - if (RNODE_LIST(assocs)->nd_head) { - NODE *n = RNODE_LIST(tail)->nd_next; - if (!RNODE_LIST(tail)->nd_head && nd_type_p(n, NODE_LIST) && - nd_type_p((n = RNODE_LIST(n)->nd_head), NODE_HASH)) { - /* DSTAR */ - tail = RNODE_HASH(n)->nd_head; - } + NODE *n = RNODE_LIST(tail)->nd_next; + if (!RNODE_LIST(tail)->nd_head && nd_type_p(n, NODE_LIST) && + nd_type_p((n = RNODE_LIST(n)->nd_head), NODE_HASH)) { + /* DSTAR */ + tail = RNODE_HASH(n)->nd_head; } if (tail) { assocs = list_concat(assocs, tail); diff --git a/spec/bundler/bundler/settings_spec.rb b/spec/bundler/bundler/settings_spec.rb index 9ac3603caf11d0..cb689fe167d3dd 100644 --- a/spec/bundler/bundler/settings_spec.rb +++ b/spec/bundler/bundler/settings_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "bundler/settings" +require "rubygems/credential_store" +require_relative "../support/fake_credential_backend" RSpec.describe Bundler::Settings do subject(:settings) { described_class.new(bundled_app) } @@ -276,6 +278,409 @@ expect(settings.credentials_for(uri)).to eq(credentials) end end + + context "with credential_store enabled" do + let(:fake_store) { Gem::CredentialStore.new(backend: FakeCredentialBackend.new) } + + before do + settings.set_local "credential_store", "true" + Gem::CredentialStore.instance = fake_store + end + + after { Gem::CredentialStore.reset! } + + it "returns nil when nothing is configured anywhere" do + expect(settings.credentials_for(uri)).to be_nil + end + + it "round-trips credentials set under the full URL" do + settings.set_local "https://gemserver.example.org/", credentials + + expect(settings.credentials_for(uri)).to eq(credentials) + end + + it "round-trips credentials set under the hostname" do + settings.set_local "gemserver.example.org", credentials + + expect(settings.credentials_for(uri)).to eq(credentials) + end + + it "keeps a password in the source URL out of the store account" do + # The account reaches the backend as a command argument, where any + # other user on the machine can read it. + recorder = Class.new(FakeCredentialBackend) do + def accounts_seen + @accounts_seen ||= [] + end + + def get(service, account) + accounts_seen << account + super + end + end.new + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: recorder) + settings.set_local "gemserver.example.org", credentials + + with_auth = Gem::URI("https://someone:s3cr3t@gemserver.example.org") + + expect(settings.credentials_for(with_auth)).to eq(credentials) + # key_for upcases, so compare without regard to case. + expect(recorder.accounts_seen).not_to be_empty + expect(recorder.accounts_seen.join.downcase).not_to include("s3cr3t") + end + + it "matches a URL key regardless of a trailing slash, like the config file does" do + settings.set_local "https://gemserver.example.org", credentials + + expect(settings.credentials_for(Gem::URI("https://gemserver.example.org/"))).to eq(credentials) + end + + it "does not write the secret to the local config file" do + settings.set_local "gemserver.example.org", credentials + + expect(settings.locations("gemserver.example.org")[:local]).to be_nil + end + + it "prefers a credential given in the environment over the credential_store" do + settings.set_local "gemserver.example.org", credentials + + ENV["BUNDLE_GEMSERVER__EXAMPLE__ORG"] = "ci:token" + env_settings = Bundler::Settings.new(bundled_app) + + expect(env_settings.credentials_for(uri)).to eq("ci:token") + end + + it "leaves the layer order alone for a host the store does not hold" do + # Written straight to the config file so the store never holds it. + # Resolution must then be exactly what it was before the store + # existed, with local beating env. + allow(settings).to receive(:active_credential_store).and_return(nil) + settings.set_local "other.example.org", "local:pass" + allow(settings).to receive(:active_credential_store).and_call_original + + ENV["BUNDLE_OTHER__EXAMPLE__ORG"] = "env:pass" + env_settings = Bundler::Settings.new(bundled_app) + + expect(env_settings.credentials_for(Gem::URI("https://other.example.org/"))).to eq("local:pass") + end + + it "falls back to the credential_store when the environment has no entry" do + settings.set_local "gemserver.example.org", credentials + + ENV["BUNDLE_OTHER__EXAMPLE__ORG"] = "ci:token" + env_settings = Bundler::Settings.new(bundled_app) + + expect(env_settings.credentials_for(uri)).to eq(credentials) + end + + it "prefers the credential_store over a stale local config value" do + # A plain-text credential left in the config file before the store + # was enabled, simulated by routing this one write to the file. + allow(settings).to receive(:active_credential_store).and_return(nil) + settings.set_local "gemserver.example.org", "stale:value" + allow(settings).to receive(:active_credential_store).and_call_original + + settings.set_local "gemserver.example.org", credentials + + expect(settings.credentials_for(uri)).to eq(credentials) + end + end + + context "with a named credential_store backend" do + let(:fake_store) { Gem::CredentialStore.new(backend: FakeCredentialBackend.new) } + + before do + settings.set_local "credential_store", "1password" + Gem::CredentialStore.instance = fake_store + end + + after { Gem::CredentialStore.reset! } + + it "round-trips credentials through the selected backend" do + settings.set_local "gemserver.example.org", credentials + + expect(settings.credentials_for(uri)).to eq(credentials) + end + end + + context "with a per-host credential_store backend" do + let(:host_backend) { FakeCredentialBackend.new } + let(:global_backend) { FakeCredentialBackend.new } + let(:service) { Bundler::Settings::CREDENTIAL_STORE_SERVICE } + + before do + Gem::CredentialStore.register_backend("fake-host", host_backend) + Gem::CredentialStore.register_backend("fake-global", global_backend) + settings.set_local "credential_store", "fake-global" + settings.set_local "credential_store.gemserver.example.org", "fake-host" + end + + after { Gem::CredentialStore.reset! } + + it "reads the host's credentials from the backend selected for that host" do + host_backend.set(service, Bundler::Settings.key_for("gemserver.example.org"), credentials) + + expect(settings.credentials_for(uri)).to eq(credentials) + end + + it "does not chain to the global backend when the host's backend misses" do + global_backend.set(service, Bundler::Settings.key_for("gemserver.example.org"), credentials) + + expect(settings.credentials_for(uri)).to be_nil + end + + it "keeps other hosts on the globally selected backend" do + global_backend.set(service, Bundler::Settings.key_for("other.example.org"), credentials) + + expect(settings.credentials_for(Gem::URI("https://other.example.org/"))).to eq(credentials) + end + + it "writes a host credential to the backend selected for that host" do + settings.set_local "gemserver.example.org", credentials + + expect(host_backend.get(service, Bundler::Settings.key_for("gemserver.example.org"))).to eq(credentials) + expect(global_backend.get(service, Bundler::Settings.key_for("gemserver.example.org"))).to be_nil + end + + it "writes a URL-keyed credential to the backend selected for its host" do + settings.set_local "https://gemserver.example.org/", credentials + + expect(host_backend.get(service, Bundler::Settings.key_for("https://gemserver.example.org/"))).to eq(credentials) + end + + it "keeps a host on the config file when its store is set to false" do + settings.set_local "credential_store.gemserver.example.org", "false" + + settings.set_local "gemserver.example.org", credentials + + expect(settings.locations("gemserver.example.org")[:local]).to eq(credentials) + expect(settings.credentials_for(uri)).to eq(credentials) + end + end + + context "with credential_store set to false" do + before { settings.set_local "credential_store", "false" } + + it "does not consult a credential store" do + expect(Gem::CredentialStore).not_to receive(:for) + expect(settings.credentials_for(uri)).to be_nil + end + end + + context "when the paired RubyGems has no credential store" do + before do + settings.set_local "credential_store", "true" + allow(settings).to receive(:require).and_call_original + allow(settings).to receive(:require).with("rubygems/credential_store").and_raise(LoadError) + end + + it "warns once and falls back to the config file without raising" do + allow(Bundler.ui).to receive(:warn) + + expect { settings.set_local "gemserver.example.org", "username:password" }.not_to raise_error + expect(settings.credentials_for(uri)).to eq("username:password") + + expect(Bundler.ui).to have_received(:warn).once + end + end + end + + describe "credential storage with credential_store enabled" do + let(:fake_store) { Gem::CredentialStore.new(backend: FakeCredentialBackend.new) } + + before do + settings.set_local "credential_store", "true" + Gem::CredentialStore.instance = fake_store + end + + after { Gem::CredentialStore.reset! } + + it "writes a host credential to the credential_store instead of the local config file" do + settings.set_local "gemserver.example.org", "username:password" + + expect(fake_store.get(Bundler::Settings.key_for("gemserver.example.org"))).to eq("username:password") + expect(settings.locations("gemserver.example.org")[:local]).to be_nil + end + + it "leaves the credential_store alone for temporary settings" do + settings.set_local "gemserver.example.org", "username:password" + stored = Bundler::Settings.key_for("gemserver.example.org") + + settings.temporary("gemserver.example.org" => "temp:value") do + # The temporary value must not be persisted to the OS store... + expect(fake_store.get(stored)).to eq("username:password") + end + + # ...and restoring it must not delete the real credential either. + expect(fake_store.get(stored)).to eq("username:password") + end + + it "names the host and the file it fell back to when the store write fails" do + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) + allow(Bundler.ui).to receive(:warn) + + settings.set_local "gemserver.example.org", "username:password" + + expect(Bundler.ui).to have_received(:warn). + with(%r{credential for gemserver\.example\.org .* #{Regexp.escape(bundled_app.to_s)}/config in plain text}m) + end + + it "warns when the credential cannot be removed from the credential_store" do + # A usable backend that refuses to delete. A missing backend never held + # the credential, so that case must stay silent. + refusing = Class.new(FakeCredentialBackend) do + def delete(_service, _account) + false + end + end.new + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: refusing) + allow(Bundler.ui).to receive(:warn) + + settings.set_local "gemserver.example.org", nil + + expect(Bundler.ui).to have_received(:warn).with(/Could not remove the credential for gemserver\.example\.org/) + end + + it "says the store was unreachable rather than claiming a removal" do + # Nothing can be removed from a store that cannot be reached. Reporting + # a clean removal would be a lie, and reporting a failure would be one + # too, so the warning says which it is. + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) + allow(Bundler.ui).to receive(:warn) + + settings.set_local "gemserver.example.org", nil + + expect(Bundler.ui).to have_received(:warn).with(/enabled but unavailable/) + expect(Bundler.ui).not_to have_received(:warn).with(/Could not remove/) + end + + it "removes the stored credential when the same host is set to a value it cannot store" do + settings.set_local "gemserver.example.org", "username:password" + expect(fake_store.get(Bundler::Settings.key_for("gemserver.example.org"))).to eq("username:password") + + # A bare token has no colon, so it goes to the config file. The stored + # user:pass must not stay behind and keep winning in #credentials_for. + settings.set_local "gemserver.example.org", "baretoken" + + expect(fake_store.get(Bundler::Settings.key_for("gemserver.example.org"))).to be_nil + expect(settings.credentials_for(Gem::URI("https://gemserver.example.org/"))).to eq("baretoken") + end + + it "routes credential store warnings to Bundler.ui" do + # Bundler replaces Gem.ui with a Gem::SilentUI subclass, so a warning + # left on Gem.ui would never reach the user during a bundle command. + allow(Bundler.ui).to receive(:warn) + settings.set_local "gemserver.example.org", "username:password" + + Gem::CredentialStore.warn_once "store trouble" + + expect(Bundler.ui).to have_received(:warn).with("store trouble") + end + + it "lists stored credentials by key" do + settings.set_local "gemserver.example.org", "username:password" + + expect(settings.all_including_stored_credentials).to include("gemserver.example.org") + end + + it "keeps stored credentials out of the hot-path key list" do + settings.set_local "gemserver.example.org", "username:password" + + # #all is read per gem source and per download, and its keys go into + # the User-Agent, so the store must not be consulted there. + expect(settings.all).not_to include("gemserver.example.org") + end + + it "omits stored credentials when the backend cannot enumerate them" do + # A resolver-style third-party backend has nothing to list. + no_list = Class.new(FakeCredentialBackend) do + undef_method :list + end.new + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: no_list) + + settings.set_local "gemserver.example.org", "username:password" + + expect(settings.all_including_stored_credentials).not_to include("gemserver.example.org") + expect(settings.credential_stored?("gemserver.example.org")).to be true + end + + it "reports a stored credential without revealing it" do + settings.set_local "gemserver.example.org", "username:password" + + values = settings.pretty_values_for("gemserver.example.org") + + expect(values).to include(/Set in the credential store, which is used ahead of the config files/) + expect(values.join).not_to include("password") + expect(settings.credential_stored?("gemserver.example.org")).to be true + end + + it "does not claim a credential is stored when it is not" do + expect(settings.credential_stored?("gemserver.example.org")).to be false + expect(settings.credential_stored?("jobs")).to be false + end + + it "leaves settings that are not host names in the config file" do + # ssl_client_cert is not on any known-settings list and a Windows path + # contains a colon, so a default-allow rule would move it into the + # store, and Settings#[] would then read it back as nil. + settings.set_local "ssl_client_cert", 'C:\certs\client.pem' + settings.set_local "user_agent", "MyCorp/1.0 (build: 123)" + + expect(settings["ssl_client_cert"]).to eq('C:\certs\client.pem') + expect(settings["user_agent"]).to eq("MyCorp/1.0 (build: 123)") + expect(fake_store.get(Bundler::Settings.key_for("ssl_client_cert"))).to be_nil + end + + it "stores a credential keyed by a host with a port" do + settings.set_local "my-registry.example.com:8080", "username:password" + + expect(fake_store.get(Bundler::Settings.key_for("my-registry.example.com:8080"))).to eq("username:password") + end + + it "does not route non-credential-shaped values to the credential_store" do + settings.set_local "jobs", "4" + + expect(settings["jobs"]).to eq(4) + end + + it "does not route the gem.push_key signing key path to the credential_store" do + settings.set_local "gem.push_key", "/path/to/key.pem" + + expect(settings["gem.push_key"]).to eq("/path/to/key.pem") + end + + it "falls back to the local config file and warns when the credential_store write fails" do + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) + allow(Bundler.ui).to receive(:warn) + + settings.set_local "gemserver.example.org", "username:password" + + expect(settings["gemserver.example.org"]).to eq("username:password") + expect(Bundler.ui).to have_received(:warn).once + end + + it "removes a stale plaintext credential from the config file once it moves to the store" do + # written to the config file before the store took over + allow(settings).to receive(:active_credential_store).and_return(nil) + settings.set_local "gemserver.example.org", "old:secret" + expect(settings.locations("gemserver.example.org")[:local]).to eq("old:secret") + allow(settings).to receive(:active_credential_store).and_call_original + + settings.set_local "gemserver.example.org", "new:secret" + + expect(fake_store.get(Bundler::Settings.key_for("gemserver.example.org"))).to eq("new:secret") + expect(settings.locations("gemserver.example.org")[:local]).to be_nil + end + + it "removes a credential_store-stored credential on unset" do + account = Bundler::Settings.key_for("gemserver.example.org") + settings.set_local "gemserver.example.org", "username:password" + expect(fake_store.get(account)).to eq("username:password") + + settings.set_local "gemserver.example.org", nil + + expect(fake_store.get(account)).to be_nil + end end describe "URI normalization" do diff --git a/spec/bundler/bundler/source/git_spec.rb b/spec/bundler/bundler/source/git_spec.rb index 14e91c6bdce2b9..59b145ef17adba 100644 --- a/spec/bundler/bundler/source/git_spec.rb +++ b/spec/bundler/bundler/source/git_spec.rb @@ -120,4 +120,33 @@ end end end + + describe "#cache" do + let(:options) do + { "uri" => uri, "revision" => "123abc" } + end + let(:app_cache_path) { Pathname.new("vendor/cache/bar-123abc") } + let(:git_proxy_stub) do + instance_double(Bundler::Source::Git::GitProxy, revision: "123abc", copy_to: nil) + end + + before do + allow(Bundler::Source::Git::GitProxy).to receive(:new).and_return(git_proxy_stub) + allow(Bundler.settings).to receive(:[]).and_call_original + allow(Bundler.settings).to receive(:[]).with(:cache_all).and_return(true) + allow(subject).to receive(:app_cache_path).and_return(app_cache_path) + allow(subject).to receive(:cache_path).and_return(Pathname.new("global/git/bar-123abc")) + allow(subject).to receive(:requires_checkout?).and_return(false) + allow(subject).to receive(:serialize_gemspecs_in) + allow(::Bundler::FileUtils).to receive(:rm_rf) + end + + it "copies the repository only once when several gems share the same source" do + subject.cache(double("spec for gem a")) + subject.cache(double("spec for gem b")) + + expect(git_proxy_stub).to have_received(:copy_to).once + expect(::Bundler::FileUtils).to have_received(:rm_rf).once + end + end end diff --git a/spec/bundler/cache/git_spec.rb b/spec/bundler/cache/git_spec.rb index f0bff333cb4bd0..8cc0a9c7fa6b6f 100644 --- a/spec/bundler/cache/git_spec.rb +++ b/spec/bundler/cache/git_spec.rb @@ -83,6 +83,29 @@ expect(the_bundle).to include_gems "foo 1.0" end + # The copy count is asserted in spec/bundler/source/git_spec.rb, which can + # observe `copy_to` in-process. + it "caches a repository shared by multiple gems" do + build_git "foo", path: lib_path("shared") + git = build_git "bar", path: lib_path("shared") + ref = git.ref_for("main", 11) + + install_gemfile <<-G + source "https://gem.repo1" + git "#{lib_path("shared")}" do + gem "foo" + gem "bar" + end + G + + bundle :cache + + expect(bundled_app("vendor/cache/shared-#{ref}")).to exist + + FileUtils.rm_r lib_path("shared") + expect(the_bundle).to include_gems "foo 1.0", "bar 1.0" + end + it "tracks updates" do git = build_git "foo" old_ref = git.ref_for("main", 11) diff --git a/spec/bundler/install/git_spec.rb b/spec/bundler/install/git_spec.rb index 57c4743e85f9e9..7b58e5ff038b2f 100644 --- a/spec/bundler/install/git_spec.rb +++ b/spec/bundler/install/git_spec.rb @@ -10,7 +10,7 @@ gem "foo", :git => "#{lib_path("foo")}" G - expect(out).to include("Using foo 1.0 from #{lib_path("foo")} (at main@#{revision_for(lib_path("foo"))[0..6]})") + expect(out).to include("Using foo 1.0 from #{lib_path("foo")} (at #{revision_for(lib_path("foo"))[0..6]})") expect(the_bundle).to include_gems "foo 1.0", source: "git@#{lib_path("foo")}" end @@ -23,7 +23,7 @@ gem "foo", :git => "#{relative_path}" G - expect(out).to include("Using foo 1.0 from #{relative_path} (at main@#{revision_for(lib_path("foo"))[0..6]})") + expect(out).to include("Using foo 1.0 from #{relative_path} (at #{revision_for(lib_path("foo"))[0..6]})") expect(the_bundle).to include_gems "foo 1.0", source: "git@#{lib_path("foo")}" end @@ -35,7 +35,7 @@ gem "foo", :git => "#{lib_path("foo")}" G - expect(out).to include("Using foo 1.0 from #{lib_path("foo")} (at non-standard@#{revision_for(lib_path("foo"))[0..6]})") + expect(out).to include("Using foo 1.0 from #{lib_path("foo")} (at #{revision_for(lib_path("foo"))[0..6]})") expect(the_bundle).to include_gems "foo 1.0", source: "git@#{lib_path("foo")}" end @@ -193,7 +193,7 @@ gem "foo", :git => "#{lib_path("foo")}" G - expect(out).to include("Using foo 1.0 from #{lib_path("foo")} (at main@#{rev[0..6]})") + expect(out).to include("Using foo 1.0 from #{lib_path("foo")} (at #{rev[0..6]})") expect(the_bundle).to include_gems "foo 1.0", source: "git@#{lib_path("foo")}" old_lockfile = lockfile @@ -202,20 +202,20 @@ rev2 = revision_for(lib_path("foo")) bundle :update, all: true, verbose: true - expect(out).to include("Using foo 2.0 (was 1.0) from #{lib_path("foo")} (at main@#{rev2[0..6]})") + expect(out).to include("Using foo 2.0 (was 1.0) from #{lib_path("foo")} (at #{rev2[0..6]})") expect(out).to include("Removing foo (#{rev[0..11]})") expect(the_bundle).to include_gems "foo 2.0", source: "git@#{lib_path("foo")}" lockfile(old_lockfile) bundle :install, verbose: true - expect(out).to include("Using foo 1.0 from #{lib_path("foo")} (at main@#{rev[0..6]})") + expect(out).to include("Using foo 1.0 from #{lib_path("foo")} (at #{rev[0..6]})") expect(the_bundle).to include_gems "foo 1.0", source: "git@#{lib_path("foo")}" end context "when install directory exists" do let(:checkout_confirmation_log_message) { "Checking out revision" } - let(:using_foo_confirmation_log_message) { "Using foo 1.0 from #{lib_path("foo")} (at main@#{revision_for(lib_path("foo"))[0..6]})" } + let(:using_foo_confirmation_log_message) { "Using foo 1.0 from #{lib_path("foo")} (at #{revision_for(lib_path("foo"))[0..6]})" } context "and no contents besides .git directory are present" do it "reinstalls gem" do diff --git a/spec/bundler/runtime/setup_spec.rb b/spec/bundler/runtime/setup_spec.rb index 46b88513d47fd6..2f5426d4f3c79a 100644 --- a/spec/bundler/runtime/setup_spec.rb +++ b/spec/bundler/runtime/setup_spec.rb @@ -890,4 +890,40 @@ def clean_load_path(lp) expect(lines).to include("LS MANPAGE") end end + + context "when Ruby::Box is enabled" do + before do + skip "requires Ruby 4.0.6+, where each box loads its own RubyGems" unless defined?(Ruby::Box) && Gem.ruby_version >= Gem::Version.new("4.0.6") + + build_lib "foo", "1.0", path: bundled_app + + install_gemfile <<-G + source "https://gem.repo1" + gemspec + G + end + + it "evaluates gemspecs in the box Bundler is loaded in" do + ruby <<~RUBY, env: { "RUBY_BOX" => "1" } + box = Ruby::Box.new + box.eval(<<~'BOX') + require "bundler/setup" + require "foo" + puts FOO + BOX + RUBY + + expect(out).to eq("1.0") + end + + it "still evaluates gemspecs when no box is created" do + ruby <<~RUBY, env: { "RUBY_BOX" => "1" } + require "bundler/setup" + require "foo" + puts FOO + RUBY + + expect(out).to eq("1.0") + end + end end diff --git a/spec/bundler/spec_helper.rb b/spec/bundler/spec_helper.rb index cc49ce8c1c2957..f4030e70a6fa16 100644 --- a/spec/bundler/spec_helper.rb +++ b/spec/bundler/spec_helper.rb @@ -138,6 +138,11 @@ def self.ruby=(ruby) ENV["XDG_CONFIG_HOME"] = nil ENV["XDG_CACHE_HOME"] = nil ENV["GEMRC"] = nil + # Left set, these point the suite at the real OS credential store, where + # specs that configure a host credential would write into the developer's + # own keychain. + ENV["BUNDLE_CREDENTIAL_STORE"] = nil + ENV["RUBYGEMS_CREDENTIAL_STORE"] = nil # Prevent tests from modifying the user's global git config. # GIT_CONFIG_GLOBAL and GIT_CONFIG_NOSYSTEM are available since Git 2.32. diff --git a/spec/bundler/support/fake_credential_backend.rb b/spec/bundler/support/fake_credential_backend.rb new file mode 100644 index 00000000000000..662dfb1dfa16b5 --- /dev/null +++ b/spec/bundler/support/fake_credential_backend.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# An in-memory Gem::CredentialStore backend for specs that exercise +# credential-store-enabled code paths without touching a real OS credential store. +class FakeCredentialBackend + def initialize + @data = {} + end + + def get(service, account) + @data[[service, account]] + end + + def set(service, account, secret) + @data[[service, account]] = secret + true + end + + def delete(service, account) + @data.delete([service, account]) + true + end + + def list(service) + @data.keys.select {|entry_service, _account| entry_service == service }.map(&:last) + end + + def delete_all(service) + @data.reject! {|(entry_service, _account), _secret| entry_service == service } + true + end +end diff --git a/spec/bundler/support/path.rb b/spec/bundler/support/path.rb index 2e7fcd95455dbc..5c107758518078 100644 --- a/spec/bundler/support/path.rb +++ b/spec/bundler/support/path.rb @@ -359,7 +359,7 @@ def git_ls_files(glob) end def tracked_files_glob - ruby_core? ? "libexec/bundle* lib/bundler lib/bundler.rb lib/rubygems/vendor/uri lib/rubygems/vendor/securerandom lib/rubygems/vendor/pub_grub lib/rubygems/yaml_serializer.rb lib/rubygems/compact_index_client* spec/bundler man/bundle*" : "exe/bundle exe/bundler lib/bundler lib/bundler.rb lib/rubygems/vendor/uri lib/rubygems/vendor/securerandom lib/rubygems/vendor/pub_grub lib/rubygems/yaml_serializer.rb lib/rubygems/compact_index_client* bundler.gemspec CHANGELOG-bundler.md LICENSE-bundler.md README-bundler.md" + ruby_core? ? "libexec/bundle* lib/bundler lib/bundler.rb lib/rubygems/vendor/uri lib/rubygems/vendor/securerandom lib/rubygems/vendor/pub_grub lib/rubygems/yaml_serializer.rb lib/rubygems/compact_index_client* lib/rubygems/credential_store* spec/bundler man/bundle*" : "exe/bundle exe/bundler lib/bundler lib/bundler.rb lib/rubygems/vendor/uri lib/rubygems/vendor/securerandom lib/rubygems/vendor/pub_grub lib/rubygems/yaml_serializer.rb lib/rubygems/compact_index_client* lib/rubygems/credential_store* bundler.gemspec CHANGELOG-bundler.md LICENSE-bundler.md README-bundler.md" end def lib_tracked_files_glob diff --git a/spec/bundler/update/git_spec.rb b/spec/bundler/update/git_spec.rb index 526e988ab7c7e3..17955672f542dc 100644 --- a/spec/bundler/update/git_spec.rb +++ b/spec/bundler/update/git_spec.rb @@ -234,7 +234,7 @@ update_git "rails", "3.0", path: lib_path("rails"), gemspec: true bundle "update", all: true - expect(out).to include("Using rails 3.0 (was 2.3.2) from #{lib_path("rails")} (at main@#{revision_for(lib_path("rails"))[0..6]})") + expect(out).to include("Using rails 3.0 (was 2.3.2) from #{lib_path("rails")} (at #{revision_for(lib_path("rails"))[0..6]})") end end diff --git a/spec/ruby/library/net-http/httpheader/content_length_spec.rb b/spec/ruby/library/net-http/httpheader/content_length_spec.rb index c66d5673c1dda4..50f01e9516b54e 100644 --- a/spec/ruby/library/net-http/httpheader/content_length_spec.rb +++ b/spec/ruby/library/net-http/httpheader/content_length_spec.rb @@ -19,12 +19,26 @@ it "returns the value of the 'Content-Length' header entry as an Integer" do @headers["Content-Length"] = "123" @headers.content_length.should.eql?(123) + end - @headers["Content-Length"] = "123valid" - @headers.content_length.should.eql?(123) + ruby_version_is ""..."4.1" do + it "ignores the parts of the 'Content-Length' header entry around the digits" do + @headers["Content-Length"] = "123valid" + @headers.content_length.should.eql?(123) - @headers["Content-Length"] = "valid123" - @headers.content_length.should.eql?(123) + @headers["Content-Length"] = "valid123" + @headers.content_length.should.eql?(123) + end + end + + ruby_version_is "4.1" do + it "raises a Net::HTTPHeaderSyntaxError if the 'Content-Length' header entry is not entirely digits" do + @headers["Content-Length"] = "123valid" + -> { @headers.content_length }.should.raise(Net::HTTPHeaderSyntaxError) + + @headers["Content-Length"] = "valid123" + -> { @headers.content_length }.should.raise(Net::HTTPHeaderSyntaxError) + end end end diff --git a/symbol.c b/symbol.c index 492b3b495195e8..32276492d215e0 100644 --- a/symbol.c +++ b/symbol.c @@ -235,6 +235,7 @@ id_entry_dir_free(void *ptr) { struct id_entry_dir *dir = ptr; SIZED_FREE_N(dir->entries, dir->capa); + xfree(dir); } static size_t diff --git a/test/net/http/test_http.rb b/test/net/http/test_http.rb index e5028d4ef6a0ca..590f8a2fe36ec1 100644 --- a/test/net/http/test_http.rb +++ b/test/net/http/test_http.rb @@ -1171,6 +1171,48 @@ def test_info end end +class TestNetHTTPInformationalResponses < Test::Unit::TestCase + CONFIG = { + 'host' => '127.0.0.1', + 'proxy_host' => nil, + 'proxy_port' => nil, + } + + include TestNetHTTPUtils + + def mount_informational(count) + @server.mount('/info', proc {|req, res| + count.times { req.continue } + res.body = 'BODY' + }) + end + + def test_informational_responses + mount_informational 3 + start {|http| + res = http.get('/info') + assert_equal('BODY', res.body) + } + end + + def test_max_informational_responses + mount_informational Net::HTTPResponse::MAX_INFORMATIONAL_RESPONSES + start {|http| + res = http.get('/info') + assert_equal('BODY', res.body) + } + end + + def test_too_many_informational_responses + mount_informational Net::HTTPResponse::MAX_INFORMATIONAL_RESPONSES + 1 + start {|http| + assert_raise(Net::HTTPBadResponse) { + http.get('/info') + } + } + end +end + class TestNetHTTPKeepAlive < Test::Unit::TestCase CONFIG = { 'host' => '127.0.0.1', @@ -1262,17 +1304,18 @@ def closed? end def write(_) end - def readline + def readuntil(terminator, ignore_eof = false, limit: nil) + raise "unexpected terminator #{terminator.dump}" unless terminator == "\n" + # A header read ends the headers at once. Every other read is the + # status line of a fresh attempt, which is what count measures. + return "" if ignore_eof @count += 1 if @success_after && @success_after <= @count - "HTTP/1.1 200 OK" + "HTTP/1.1 200 OK\n" else raise Errno::ECONNRESET end end - def readuntil(*_) - "" - end def read_all(_) end end diff --git a/test/net/http/test_httpheader.rb b/test/net/http/test_httpheader.rb index f4b786037f8a03..3c09f4c194a41e 100644 --- a/test/net/http/test_httpheader.rb +++ b/test/net/http/test_httpheader.rb @@ -396,12 +396,17 @@ def test_content_length try_content_length 500, '500' try_content_length 10000_0000_0000, '1000000000000' try_content_length 123, ' 123' - try_content_length 1, '1 23' - try_content_length 500, '(OK)500' - assert_raise(Net::HTTPHeaderSyntaxError, 'here is no digit, but') { - @c['content-length'] = 'no digit' - @c.content_length - } + + # Same values in one Content-Length field are accepted. + # See: https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6-13 + try_content_length 5, '5, 5' + + # Same values in multiple Content-Length fields are accepted. + # See: https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6-13 + @c.delete('content-length') + @c.add_field('content-length', '7') + @c.add_field('content-length', '7') + assert_equal 7, @c.content_length end def try_content_length(len, str) @@ -409,6 +414,37 @@ def try_content_length(len, str) assert_equal len, @c.content_length end + def test_content_length_invalid + try_invalid_content_length '' + try_invalid_content_length '1 23' + try_invalid_content_length '(OK)500' + try_invalid_content_length 'no digit' + try_invalid_content_length 'abc5' + try_invalid_content_length '5abc' + try_invalid_content_length '5, 6' + + @c.delete('content-length') + @c.add_field('content-length', '7') + @c.add_field('content-length', '8') + assert_raise(Net::HTTPHeaderSyntaxError) { + @c.content_length + } + + @c.delete('content-length') + @c.add_field('content-length', '5') + @c.add_field('content-length', '') + assert_raise(Net::HTTPHeaderSyntaxError) { + @c.content_length + } + end + + def try_invalid_content_length(str) + @c['content-length'] = str + assert_raise(Net::HTTPHeaderSyntaxError, str) { + @c.content_length + } + end + def test_content_length= @c.content_length = 0 assert_equal 0, @c.content_length diff --git a/test/net/http/test_httpresponse.rb b/test/net/http/test_httpresponse.rb index 7e7ae8a8e2418a..e9ed1213516210 100644 --- a/test/net/http/test_httpresponse.rb +++ b/test/net/http/test_httpresponse.rb @@ -25,9 +25,10 @@ def test_response_header_too_large count.times { |i| headers << "X-Pad-#{i}: #{big_value}\n" } headers << "\nhello\n" io = dummy_io(headers) - assert_raise(Net::HTTPBadResponse) do + e = assert_raise(Net::HTTPBadResponse) do Net::HTTPResponse.read_new(io) end + assert_equal 'response header too large', e.message end def test_response_header_within_limit @@ -42,6 +43,40 @@ def test_response_header_within_limit end end + def test_status_line_too_long + io = endless_io("HTTP/1.1 200 ") + assert_raise(Net::HTTPBadResponse) do + Net::HTTPResponse.read_new(io) + end + end + + def test_response_header_line_too_long + io = endless_io("HTTP/1.1 200 OK\nX-Foo: ") + assert_raise(Net::HTTPBadResponse) do + Net::HTTPResponse.read_new(io) + end + end + + def test_chunk_size_line_too_long + io = endless_io("HTTP/1.1 200 OK\nTransfer-Encoding: chunked\n\n") + res = Net::HTTPResponse.read_new(io) + assert_raise(Net::HTTPBadResponse) do + res.reading_body io, true do + res.read_body + end + end + end + + def test_chunk_trailer_line_too_long + io = endless_io("HTTP/1.1 200 OK\nTransfer-Encoding: chunked\n\n0\n") + res = Net::HTTPResponse.read_new(io) + assert_raise(Net::HTTPBadResponse) do + res.reading_body io, true do + res.read_body + end + end + end + def test_multiline_header io = dummy_io(< SAFETY_LIMIT + @buf << 'a' * (size - @buf.bytesize) if @buf.bytesize < size + s = @buf.slice!(0, size) + buf ? buf.replace(s) : s + end + + def closed? + false + end + end + + def endless_io(prefix) + Net::BufferedIO.new(EndlessDataIO.new(prefix.gsub(/\n/, "\r\n"))) + end end diff --git a/test/net/http/utils.rb b/test/net/http/utils.rb index 3618a92b774535..cdba47d60b6e74 100644 --- a/test/net/http/utils.rb +++ b/test/net/http/utils.rb @@ -209,9 +209,9 @@ def finish def build_response_headers response = "HTTP/1.1 #{@status} #{status_message(@status)}\r\n" if @chunked - @headers['Transfer-Encoding'] = 'chunked' + self['Transfer-Encoding'] = 'chunked' else - @headers['Content-Length'] = @body.bytesize.to_s + self['Content-Length'] ||= @body.bytesize.to_s end @headers.each do |key, value| response << "#{key}: #{value}\r\n" diff --git a/test/openssl/test_bn.rb b/test/openssl/test_bn.rb index f663102d45c2a7..4e565f090c3069 100644 --- a/test/openssl/test_bn.rb +++ b/test/openssl/test_bn.rb @@ -315,6 +315,14 @@ def test_comparison assert_instance_of(String, @e1.hash.to_s) end + def test_marshal + assert_equal(@e1, Marshal.load(Marshal.dump(@e1))) + assert_equal(@e2, Marshal.load(Marshal.dump(@e2))) + + obj = Marshal.load("\x04\x08U:\x10OpenSSL::BNi\xfe\x01\x00") + assert_equal(-0xffff, obj) + end + def test_argument_error bug15760 = '[ruby-core:92231] [Bug #15760]' assert_raise(ArgumentError, bug15760) { OpenSSL::BN.new(nil, 2) } diff --git a/test/ruby/test_literal.rb b/test/ruby/test_literal.rb index cff888d4b3a5d4..bc87cf55c93b1e 100644 --- a/test/ruby/test_literal.rb +++ b/test/ruby/test_literal.rb @@ -521,6 +521,10 @@ def test_hash_duplicated_key ) do |key| assert_warning(/key #{Regexp.quote(eval(key).inspect)} is duplicated/) { eval("{#{key} => :bar, #{key} => :foo}") } end + + assert_warning(/key :foo is duplicated/, "[Bug #22264]") do + eval("{**{}, foo: :bar, **{foo: :foo}}") + end end def test_hash_frozen_key_id diff --git a/test/rubygems/fake_credential_backend.rb b/test/rubygems/fake_credential_backend.rb new file mode 100644 index 00000000000000..3a692d4566f713 --- /dev/null +++ b/test/rubygems/fake_credential_backend.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +## +# An in-memory Gem::CredentialStore backend for tests that need to exercise +# credential-store-enabled code paths without touching a real OS credential store. +# Inject it via Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: Gem::FakeCredentialBackend.new). + +class Gem::FakeCredentialBackend + def initialize + @data = {} + end + + def get(service, account) + @data[[service, account]] + end + + def set(service, account, secret) + @data[[service, account]] = secret + true + end + + def delete(service, account) + @data.delete([service, account]) + true + end + + def list(service) + @data.keys.select {|entry_service, _account| entry_service == service }.map(&:last) + end + + def delete_all(service) + @data.reject! {|(entry_service, _account), _secret| entry_service == service } + true + end +end diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 73360ced5c848c..73e109796a7ba5 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -47,6 +47,7 @@ require "zlib" require_relative "mock_gem_ui" require_relative "pem_utilities" +require_relative "fake_credential_backend" # JRuby on Windows raises TypeError inside File.symlink (the wincode helper # trips on a nil path), so any test that exercises Gem::Installer's symlink @@ -382,6 +383,9 @@ def setup ENV["GEM_VENDOR"] = nil ENV["GEMRC"] = nil + # Left set, this points the suite at the real OS credential store, where + # tests that clear an API key would delete the developer's own. + ENV["RUBYGEMS_CREDENTIAL_STORE"] = nil ENV["XDG_CACHE_HOME"] = nil ENV["XDG_CONFIG_HOME"] = nil ENV["XDG_DATA_HOME"] = nil @@ -594,6 +598,19 @@ def credential_teardown FileUtils.rm_rf @temp_cred end + ## + # Runs the block with Gem::CredentialStore.instance backed by an + # in-memory Gem::FakeCredentialBackend, so credential_store-enabled code paths + # can be exercised without touching a real OS credential store. + + def with_fake_credential_store + require "rubygems/credential_store" + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: Gem::FakeCredentialBackend.new) + yield Gem::CredentialStore.instance + ensure + Gem::CredentialStore.reset! + end + def common_installer_setup common_installer_teardown diff --git a/test/rubygems/test_gem_command.rb b/test/rubygems/test_gem_command.rb index 3695f9488f340f..44e695f567389e 100644 --- a/test/rubygems/test_gem_command.rb +++ b/test/rubygems/test_gem_command.rb @@ -2,6 +2,7 @@ require_relative "helper" require "rubygems/command" +require "open3" class Gem::Command public :parser @@ -399,4 +400,21 @@ def test_show_lookup_failure_suggestions_remote assert_equal expected, @ui.error end + + def test_gem_cli_runs_under_ruby_box + omit "Ruby::Box is not available" unless defined?(Ruby::Box) + # A boxed subprocess crashes with SIGSEGV during finalization on Windows + omit "Ruby::Box is unstable on Windows" if Gem.win_platform? + # Ruby 4.0 resolves Gem::NameTuple from the root box and fails autoload + omit "Ruby::Box is too unstable before 4.1" if Gem.ruby_version < Gem::Version.new("4.1.0.a") + + boxed, warning, = Open3.capture3({ "RUBY_BOX" => "1" }, Gem.ruby, "-e", "print !Ruby::Box.current.nil?") + assert_equal "true", boxed, "RUBY_BOX=1 no longer boxes the subprocess: #{warning}" + + env = { "RUBY_BOX" => "1", "GEM_HOME" => Gem.paths.home } + run_gem = 'require "rubygems/gem_runner"; Gem::GemRunner.new.run(["list", "--local"])' + output = IO.popen(env, [*ruby_with_rubygems_in_load_path, "-e", run_gem], err: [:child, :out], &:read) + + assert Process.last_status.success?, output + end end diff --git a/test/rubygems/test_gem_commands_signin_command.rb b/test/rubygems/test_gem_commands_signin_command.rb index e612288faf91ac..74e5fba1f8aa12 100644 --- a/test/rubygems/test_gem_commands_signin_command.rb +++ b/test/rubygems/test_gem_commands_signin_command.rb @@ -22,6 +22,13 @@ def teardown super end + def test_sign_in_calls_api_key_without_arguments + # Command plugins include Gem::GemcutterUtilities and override #api_key + # with no parameters, so sign_in has to keep calling it that way. + assert_equal 0, Gem::GemcutterUtilities.instance_method(:api_key).arity + assert_empty Gem::GemcutterUtilities.instance_method(:api_key).parameters + end + def test_execute_when_not_already_signed_in sign_in_ui = util_capture { @cmd.execute } assert_match(/Signed in./, sign_in_ui.output) diff --git a/test/rubygems/test_gem_commands_signout_command.rb b/test/rubygems/test_gem_commands_signout_command.rb index 999a14080f2070..d64558f1bb8cfc 100644 --- a/test/rubygems/test_gem_commands_signout_command.rb +++ b/test/rubygems/test_gem_commands_signout_command.rb @@ -2,6 +2,7 @@ require_relative "helper" require "rubygems/commands/signout_command" +require "rubygems/credential_store" require "rubygems/installer" class TestGemCommandsSignoutCommand < Gem::TestCase @@ -21,10 +22,138 @@ def test_execute_when_user_is_signed_in assert_equal false, File.exist?(Gem.configuration.credentials_path) end + def test_execute_keeps_the_original_wording_without_the_credential_store + # Anyone who never turned the store on should read what they always read, + # since scripts match on this line. + FileUtils.mkdir_p File.dirname(Gem.configuration.credentials_path) + FileUtils.touch Gem.configuration.credentials_path + + @sign_out_ui = Gem::MockGemUi.new + use_ui(@sign_out_ui) { @cmd.execute } + + assert_match(/You have successfully signed out from all sessions\./, @sign_out_ui.output) + refute_match(/every registry/, @sign_out_ui.output) + end + + def test_execute_refuses_to_delete_a_read_only_credentials_file + pend "chmod not supported" if Gem.win_platform? + pend "running as root bypasses the write permission check" if Process.uid.zero? + + FileUtils.mkdir_p File.dirname(Gem.configuration.credentials_path) + FileUtils.touch Gem.configuration.credentials_path + File.chmod 0o400, Gem.configuration.credentials_path + + @sign_out_ui = Gem::MockGemUi.new + assert_raise Gem::MockGemUi::TermError do + use_ui(@sign_out_ui) { @cmd.execute } + end + + assert File.exist?(Gem.configuration.credentials_path) + assert_match(/Could not remove the credentials/, @sign_out_ui.error) + refute_match(/successfully signed out/, @sign_out_ui.output) + ensure + if File.exist?(Gem.configuration.credentials_path) + File.chmod 0o600, Gem.configuration.credentials_path + end + end + def test_execute_when_not_signed_in # i.e. no credential file created @sign_out_ui = Gem::MockGemUi.new use_ui(@sign_out_ui) { @cmd.execute } assert_match(/You are not currently signed in/, @sign_out_ui.error) end + + def test_execute_signs_out_of_every_registry_via_credential_store # no credentials file + Gem.configuration.credential_store = true + + with_fake_credential_store do |store| + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "rubygems-key") + store.set("https://other.example", "other-key") + + @sign_out_ui = Gem::MockGemUi.new + use_ui(@sign_out_ui) { @cmd.execute } + + assert_match(/signed out of every registry, including RubyGems\.org/, @sign_out_ui.output) + assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_nil store.get("https://other.example") + end + ensure + Gem.configuration.credential_store = false + end + + def test_execute_clears_the_credential_store_even_when_the_file_is_unremovable + pend "chmod not supported" if Gem.win_platform? + pend "running as root bypasses the write permission check" if Process.uid.zero? + + Gem.configuration.credential_store = true + + FileUtils.mkdir_p File.dirname(Gem.configuration.credentials_path) + FileUtils.touch Gem.configuration.credentials_path + File.chmod 0o400, Gem.configuration.credentials_path + + with_fake_credential_store do |store| + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "rubygems-key") + + # An unremovable credentials file is a separate problem; it must not + # keep the stored keys alive. + @sign_out_ui = Gem::MockGemUi.new + assert_raise Gem::MockGemUi::TermError do + use_ui(@sign_out_ui) { @cmd.execute } + end + + assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert File.exist?(Gem.configuration.credentials_path) + assert_match(/Could not remove the credentials from '/, @sign_out_ui.error) + refute_match(/credential store/, @sign_out_ui.error) + end + ensure + Gem.configuration.credential_store = false + if File.exist?(Gem.configuration.credentials_path) + File.chmod 0o600, Gem.configuration.credentials_path + end + end + + def test_execute_reports_a_credential_store_that_could_not_be_cleared + Gem.configuration.credential_store = true + + # A usable backend that refuses to clear. A missing backend is a + # different case: it never held anything, so there is nothing to fail at. + refusing = Class.new(Gem::FakeCredentialBackend) do + def delete_all(_service) + false + end + end.new + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: refusing) + + @sign_out_ui = Gem::MockGemUi.new + assert_raise Gem::MockGemUi::TermError do + use_ui(@sign_out_ui) { @cmd.execute } + end + + assert_match(/Could not remove the credentials from the credential store/, @sign_out_ui.error) + refute_match(/successfully signed out/, @sign_out_ui.output) + ensure + Gem::CredentialStore.reset! + Gem.configuration.credential_store = false + end + + def test_execute_succeeds_when_the_platform_has_no_credential_store + Gem.configuration.credential_store = true + + FileUtils.mkdir_p File.dirname(Gem.configuration.credentials_path) + FileUtils.touch Gem.configuration.credentials_path + + # No native backend on this platform. Nothing was ever stored, so signout + # must not report a removal failure. + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) + + @sign_out_ui = Gem::MockGemUi.new + use_ui(@sign_out_ui) { @cmd.execute } + + assert_match(/successfully signed out/, @sign_out_ui.output) + ensure + Gem::CredentialStore.reset! + Gem.configuration.credential_store = false + end end diff --git a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb index bb8c7f8727651c..1ac4ed6a03300b 100644 --- a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb +++ b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb @@ -14,6 +14,16 @@ def initialize(body) alias_method :body, :fake_body end + class FakePartialContent < Gem::Net::HTTPPartialContent + def initialize(body) + super("1.1", "206", "Partial Content") + @fake_body = body + end + + attr_reader :fake_body + alias_method :body, :fake_body + end + class FakeRedirect < Gem::Net::HTTPFound def initialize(location) super("1.1", "302", "Found") @@ -52,7 +62,10 @@ def request(uri, request_class) request = request_class.new(uri) yield request if block_given? @requests << [uri, request] - @responses.fetch(uri.to_s) + response = @responses.fetch(uri.to_s) + # A mapped exception stands in for a connection that never produced a response. + raise response if response.is_a?(Exception) + response end end @@ -70,6 +83,19 @@ def test_call_joins_path_with_base_uri assert_equal Gem::URI("https://index.example/info/a"), remote.requests.first.first end + def test_call_returns_not_modified_responses + response = Gem::Net::HTTPNotModified.new("1.1", "304", "Not Modified") + fetcher, _remote = fetcher_for("https://index.example/versions" => response) + + assert_same response, fetcher.call("versions") + end + + def test_call_returns_partial_content_responses + fetcher, _remote = fetcher_for("https://index.example/versions" => FakePartialContent.new("tail")) + + assert_equal "tail", fetcher.call("versions", "Range" => "bytes=10-").body + end + def test_call_applies_request_headers fetcher, remote = fetcher_for("https://index.example/versions" => FakeResponse.new("data")) @@ -110,6 +136,104 @@ def test_call_resolves_relative_redirect_location assert_equal "data", fetcher.call("versions").body end + def test_call_rejects_https_to_http_redirect + fetcher, remote = fetcher_for( + "https://index.example/versions" => FakeRedirect.new("http://mirror.example/versions") + ) + + error = assert_raise Gem::RemoteFetcher::FetchError do + fetcher.call("versions") + end + + assert_match(%r{redirecting to non-https resource: http://mirror\.example/versions}, error.message) + assert_equal 1, remote.requests.size + end + + def test_call_wraps_connection_refused_in_fetch_error + fetcher, remote = fetcher_for( + "https://index.example/versions" => Errno::ECONNREFUSED.new("Connection refused") + ) + + error = assert_raise Gem::RemoteFetcher::FetchError do + fetcher.call("versions") + end + + assert_match(/Errno::ECONNREFUSED/, error.message) + assert_equal 1, remote.requests.size + end + + def test_call_wraps_ssl_error_in_fetch_error + pend "OpenSSL is unavailable" unless Gem::HAVE_OPENSSL + + fetcher, _remote = fetcher_for( + "https://index.example/versions" => OpenSSL::SSL::SSLError.new("certificate verify failed") + ) + + error = assert_raise Gem::RemoteFetcher::FetchError do + fetcher.call("versions") + end + + assert_match(/OpenSSL::SSL::SSLError/, error.message) + end + + def test_call_wraps_socket_error_in_fetch_error + fetcher, _remote = fetcher_for( + "https://index.example/versions" => SocketError.new("getaddrinfo: Name or service not known") + ) + + error = assert_raise Gem::RemoteFetcher::FetchError do + fetcher.call("versions") + end + + assert_match(/SocketError/, error.message) + end + + def test_call_follows_redirects_from_an_http_source + remote = FakeRemoteFetcher.new( + "http://index.example/versions" => FakeRedirect.new("http://mirror.example/versions"), + "http://mirror.example/versions" => FakeResponse.new("data") + ) + fetcher = Gem::CompactIndexClient::HTTPFetcher.new("http://index.example", remote) + + assert_equal "data", fetcher.call("versions").body + assert_equal 2, remote.requests.size + end + + def test_call_redacts_credentials_in_rejected_redirect + fetcher, _remote = fetcher_for( + "https://index.example/versions" => FakeRedirect.new("http://user:s3cr3t@mirror.example/versions") + ) + + error = assert_raise Gem::RemoteFetcher::FetchError do + fetcher.call("versions") + end + + refute_match(/s3cr3t/, error.message) + assert_match(%r{redirecting to non-https resource: http://user:REDACTED@mirror\.example/versions}, error.message) + end + + def test_call_keeps_credentials_on_an_accepted_redirect + remote = FakeRemoteFetcher.new( + "https://user:s3cr3t@index.example/versions" => FakeRedirect.new("/v2/versions"), + "https://user:s3cr3t@index.example/v2/versions" => FakeResponse.new("data") + ) + fetcher = Gem::CompactIndexClient::HTTPFetcher.new("https://user:s3cr3t@index.example", remote) + + assert_equal "data", fetcher.call("versions").body + assert_equal "s3cr3t", remote.requests.last.first.password + end + + def test_call_drops_credentials_on_a_cross_host_redirect + remote = FakeRemoteFetcher.new( + "https://user:s3cr3t@index.example/versions" => FakeRedirect.new("https://mirror.example/versions"), + "https://mirror.example/versions" => FakeResponse.new("data") + ) + fetcher = Gem::CompactIndexClient::HTTPFetcher.new("https://user:s3cr3t@index.example", remote) + + assert_equal "data", fetcher.call("versions").body + assert_nil remote.requests.last.first.userinfo + end + def test_call_raises_after_too_many_redirects fetcher, _remote = fetcher_for( "https://index.example/versions" => FakeRedirect.new("https://index.example/versions") diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index 0ca05e7203ae00..9e8c48e05ddce6 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -2,6 +2,7 @@ require_relative "helper" require "rubygems/config_file" +require "rubygems/credential_store" class TestGemConfigFile < Gem::TestCase def setup @@ -460,6 +461,349 @@ def test_rubygems_api_key_equals_bad_permission assert_equal 0o644, stat.mode & 0o644 end + def test_credential_store_defaults_to_false + refute @cfg.credential_store + end + + def test_credential_store_from_gemrc + File.open @temp_conf, "w" do |fp| + fp.puts ":credential_store: true" + end + + util_config_file %W[--config-file=#{@temp_conf}] + + assert @cfg.credential_store + end + + def test_credential_store_from_environment_variable + with_env(ENV.to_h.merge("RUBYGEMS_CREDENTIAL_STORE" => "true")) do + util_config_file + end + + assert @cfg.credential_store + end + + def test_credential_store_reads_every_boolean_spelling_from_either_source + %w[0 no off f n].each do |off| + ENV["RUBYGEMS_CREDENTIAL_STORE"] = off + assert_equal false, Gem::ConfigFile.new([]).credential_store, "#{off.inspect} from the environment" + + File.open(@temp_conf, "w") {|fp| fp.puts ":credential_store: #{off}" } + assert_equal false, Gem::ConfigFile.new(["--config-file", @temp_conf]).credential_store, "#{off.inspect} from gemrc" + end + + %w[1 yes on t y].each do |on| + ENV["RUBYGEMS_CREDENTIAL_STORE"] = on + assert_equal true, Gem::ConfigFile.new([]).credential_store, "#{on.inspect} from the environment" + end + ensure + ENV["RUBYGEMS_CREDENTIAL_STORE"] = nil + end + + def test_credential_store_survives_an_undecodable_environment_variable + # Whatever locale the environment carries, the setting is compared against + # ASCII, and String#downcase would refuse these bytes outright. Windows + # rewrites an undecodable byte on its way through the environment, so what + # the setting has to carry through is whatever comes back out of it. + ENV["RUBYGEMS_CREDENTIAL_STORE"] = "\xff".dup.force_encoding("UTF-8") + + assert_equal ENV["RUBYGEMS_CREDENTIAL_STORE"], Gem::ConfigFile.new([]).credential_store + ensure + ENV["RUBYGEMS_CREDENTIAL_STORE"] = nil + end + + def test_credential_store_backend_name_from_gemrc + File.open @temp_conf, "w" do |fp| + fp.puts ":credential_store: 1password" + end + + util_config_file %W[--config-file=#{@temp_conf}] + + assert_equal "1password", @cfg.credential_store + end + + def test_credential_store_backend_name_from_environment_variable + with_env(ENV.to_h.merge("RUBYGEMS_CREDENTIAL_STORE" => "1password")) do + util_config_file + end + + assert_equal "1password", @cfg.credential_store + end + + def test_credential_store_false_environment_variable_keeps_default + with_env(ENV.to_h.merge("RUBYGEMS_CREDENTIAL_STORE" => "false")) do + util_config_file + end + + refute @cfg.credential_store + end + + def test_rubygems_api_key_equals_with_credential_store_writes_to_store_and_clears_file + @cfg.credential_store = true + + with_fake_credential_store do |store| + @cfg.rubygems_api_key = "x" + + assert_equal "x", @cfg.rubygems_api_key + assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + # The plaintext key from credential_setup is removed once it is stored. + refute_includes load_yaml_file(@cfg.credentials_path).keys, :rubygems_api_key + end + end + + def test_set_api_key_with_credential_store_writes_to_store_and_removes_plaintext + # A plaintext host key written before the store was enabled. + @cfg.set_api_key "https://example.org", "old" + assert_equal "old", load_yaml_file(@cfg.credentials_path)["https://example.org"] + + @cfg.credential_store = true + + with_fake_credential_store do |store| + @cfg.set_api_key "https://example.org", "new" + + assert_equal "new", store.get("https://example.org") + refute_includes load_yaml_file(@cfg.credentials_path).keys, "https://example.org" + end + end + + def test_rubygems_api_key_equals_warns_and_uses_file_when_store_write_fails + @cfg.credential_store = true + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) + + use_ui @ui do + @cfg.rubygems_api_key = "x" + end + + assert_match(/plain text/, @ui.error) + assert_equal "x", load_yaml_file(@cfg.credentials_path)[:rubygems_api_key] + ensure + Gem::CredentialStore.reset! + end + + def test_named_backend_routes_reads_and_writes_to_the_store + @cfg.credential_store = "1password" + + with_fake_credential_store do |store| + @cfg.rubygems_api_key = "x" + + assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_equal "x", @cfg.rubygems_api_key + end + end + + def test_credential_store_api_key_for_only_checks_the_host_account + @cfg.credential_store = true + + with_fake_credential_store do |store| + assert_nil @cfg.credential_store_api_key_for("https://example.org") + + # The default account must not answer for another host, or a push to + # that host would send the RubyGems.org key. + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "default-key") + assert_nil @cfg.credential_store_api_key_for("https://example.org") + + store.set("https://example.org", "host-key") + assert_equal "host-key", @cfg.credential_store_api_key_for("https://example.org") + end + end + + def test_credential_store_api_key_for_returns_nil_without_a_host + @cfg.credential_store = true + + with_fake_credential_store do |store| + store.set("", "empty-account-key") + + assert_nil @cfg.credential_store_api_key_for(nil) + assert_nil @cfg.credential_store_api_key_for("") + end + end + + def test_clearing_the_api_key_removes_it_from_the_store + @cfg.credential_store = true + + with_fake_credential_store do |store| + @cfg.rubygems_api_key = "stored-key" + @cfg.rubygems_api_key = nil + + assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_nil Gem::ConfigFile.new([]).tap {|c| c.credential_store = true }.rubygems_api_key + end + ensure + @cfg.credential_store = false + end + + def test_clearing_a_host_api_key_removes_it_from_the_store + @cfg.credential_store = true + + with_fake_credential_store do |store| + @cfg.set_api_key "https://other.example", "host-key" + @cfg.set_api_key "https://other.example", "" + + assert_nil store.get("https://other.example") + end + ensure + @cfg.credential_store = false + end + + def test_rubygems_api_key_reads_the_store_in_a_later_process + @cfg.credential_store = true + + with_fake_credential_store do + @cfg.rubygems_api_key = "stored-key" + + # A fresh ConfigFile stands in for the next process: the plain text + # copy is gone from the credentials file, so only the store has it. + fresh = Gem::ConfigFile.new([]) + fresh.credential_store = true + + assert_equal "stored-key", fresh.rubygems_api_key + end + ensure + @cfg.credential_store = false + end + + def test_credential_store_default_api_key_reads_the_default_account + @cfg.credential_store = true + + with_fake_credential_store do |store| + assert_nil @cfg.credential_store_default_api_key + + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "default-key") + assert_equal "default-key", @cfg.credential_store_default_api_key + end + end + + def test_credential_store_api_key_for_returns_nil_when_credential_store_disabled + with_fake_credential_store do |store| + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "default-key") + + assert_nil @cfg.credential_store_api_key_for("https://example.org") + end + end + + def test_storing_an_api_key_warns_when_the_plain_text_copy_cannot_be_removed + pend "chmod is not enforced for the owner on Windows" if Gem.win_platform? + pend "running as root bypasses the write permission check" if Process.uid.zero? + + @cfg.credential_store = true + + File.write @cfg.credentials_path, @cfg.class.dump_with_rubygems_yaml(rubygems_api_key: "old") + File.chmod 0o400, @cfg.credentials_path + + with_fake_credential_store do |store| + use_ui @ui do + @cfg.rubygems_api_key = "new" + end + + assert_equal "new", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_match(/plain text copy .* could not be removed/, @ui.error) + end + ensure + File.chmod 0o600, @cfg.credentials_path if File.exist?(@cfg.credentials_path) + end + + def test_falling_back_to_the_file_clears_the_stored_key + @cfg.credential_store = true + + refusing = Class.new(Gem::FakeCredentialBackend) do + def refuse_writes! + @refusing = true + end + + def set(service, account, secret) + return false if @refusing + + super + end + end.new + refusing.set("rubygems", Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "stale") + refusing.refuse_writes! + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: refusing) + + use_ui(@ui) { @cfg.rubygems_api_key = "fresh" } + + assert_nil refusing.get("rubygems", Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_equal "fresh", @cfg.rubygems_api_key + ensure + Gem::CredentialStore.reset! + @cfg.credential_store = false + end + + def test_unset_api_key_bang_removes_from_credential_store + @cfg.credential_store = true + + with_fake_credential_store do |store| + @cfg.rubygems_api_key = "x" + assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + + @cfg.unset_api_key! + + assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + end + end + + def test_credential_store_account_agrees_with_the_credentials_file_key + # The two have to name the same host, or a key written under one spelling + # cannot be found under another. Loading only this file also proves the + # account derivation brings its own URI support along. + %w[https://gems.example.com/ https://gems.example.com gems__example__com].each do |spelling| + account = Gem::ConfigFile.credential_store_account(spelling) + key = Gem::ConfigFile.normalize_credentials_key(spelling) + + assert_equal key, account, spelling + end + + assert_equal "https://gems.example.com", + Gem::ConfigFile.credential_store_account("https://user:secret@gems.example.com/") + end + + def test_unset_api_key_bang_leaves_a_read_only_credentials_file_alone + pend "chmod not supported" if Gem.win_platform? + pend "running as root bypasses the write permission check" if Process.uid.zero? + + # POSIX deletes a read-only file without protest when the directory is + # writable, so the refusal has to come from the code, as it always did. + FileUtils.mkdir_p File.dirname(@cfg.credentials_path) + FileUtils.touch @cfg.credentials_path + File.chmod 0o400, @cfg.credentials_path + + _store_cleared, file_removed = @cfg.unset_api_key! + + assert_equal false, file_removed + assert File.exist?(@cfg.credentials_path) + ensure + File.chmod 0o600, @cfg.credentials_path if File.exist?(@cfg.credentials_path) + end + + def test_unset_api_key_bang_removes_every_host_from_credential_store + @cfg.credential_store = true + + with_fake_credential_store do |store| + @cfg.rubygems_api_key = "x" + @cfg.set_api_key "https://other.example", "y" + assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_equal "y", store.get("https://other.example") + + @cfg.unset_api_key! + + assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_nil store.get("https://other.example") + end + end + + def test_rubygems_api_key_equals_falls_back_to_file_when_credential_store_unavailable + @cfg.credential_store = true + + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) + + @cfg.rubygems_api_key = "x" + + assert_equal "x", @cfg.rubygems_api_key + assert_equal({ rubygems_api_key: "x" }, load_yaml_file(@cfg.credentials_path)) + ensure + Gem::CredentialStore.reset! + end + def test_write @cfg.backtrace = false @cfg.update_sources = false diff --git a/test/rubygems/test_gem_credential_store.rb b/test/rubygems/test_gem_credential_store.rb new file mode 100644 index 00000000000000..c31feaf1e290bf --- /dev/null +++ b/test/rubygems/test_gem_credential_store.rb @@ -0,0 +1,392 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store" + +class TestGemCredentialStore < Gem::TestCase + class FakeBackend + attr_reader :calls + + def initialize + @calls = [] + @data = {} + @get_calls = 0 + end + + def get(service, account) + @calls << [:get, service, account] + @get_calls += 1 + @data[[service, account]] + end + + def set(service, account, secret) + @calls << [:set, service, account, secret] + @data[[service, account]] = secret + true + end + + def delete(service, account) + @calls << [:delete, service, account] + @data.delete([service, account]) + true + end + + def delete_all(service) + @calls << [:delete_all, service] + @data.reject! {|(entry_service, _account), _secret| entry_service == service } + true + end + + def get_call_count + @get_calls + end + end + + # Fails until #raising is turned off, so a test can show the store noticing + # that a backend started answering again. + class SometimesRaisingBackend < Gem::FakeCredentialBackend + attr_accessor :raising + + def initialize + super + @raising = true + end + + def get(service, account) + raise Errno::ENOENT, "security" if @raising + + super + end + end + + class RaisingBackend + def get(_service, _account) + raise Errno::ENOENT, "security" + end + + def set(_service, _account, _secret) + raise Errno::ENOENT, "security" + end + + def delete(_service, _account) + raise Errno::ENOENT, "security" + end + + def delete_all(_service) + raise Errno::ENOENT, "security" + end + end + + def setup + super + Gem::CredentialStore.reset! + end + + def teardown + Gem::CredentialStore.reset! + super + end + + def test_available_without_backend + store = Gem::CredentialStore.new(backend: nil) + refute store.available? + end + + def test_available_with_backend + store = Gem::CredentialStore.new(backend: FakeBackend.new) + assert store.available? + end + + def test_get_set_delete_roundtrip + store = Gem::CredentialStore.new(backend: FakeBackend.new) + + assert_nil store.get("example.org") + assert store.set("example.org", "s3cr3t") + assert_equal "s3cr3t", store.get("example.org") + assert store.delete("example.org") + end + + def test_set_uses_service_name + backend = FakeBackend.new + store = Gem::CredentialStore.new(backend: backend) + + store.set("example.org", "s3cr3t") + + assert_includes backend.calls, [:set, Gem::CredentialStore::SERVICE_NAME, "example.org", "s3cr3t"] + end + + def test_get_is_memoized_per_account + backend = FakeBackend.new + backend.set(Gem::CredentialStore::SERVICE_NAME, "example.org", "s3cr3t") + store = Gem::CredentialStore.new(backend: backend) + + 3.times { store.get("example.org") } + + assert_equal 1, backend.get_call_count + end + + def test_set_updates_cache_without_extra_get + backend = FakeBackend.new + store = Gem::CredentialStore.new(backend: backend) + + store.set("example.org", "s3cr3t") + assert_equal "s3cr3t", store.get("example.org") + + assert_equal 0, backend.get_call_count + end + + def test_delete_clears_cache + backend = FakeBackend.new + backend.set(Gem::CredentialStore::SERVICE_NAME, "example.org", "s3cr3t") + store = Gem::CredentialStore.new(backend: backend) + store.get("example.org") + + store.delete("example.org") + store.get("example.org") + + assert_equal 2, backend.get_call_count + end + + def test_operations_without_backend_are_safe_noops + store = Gem::CredentialStore.new(backend: nil) + + assert_nil store.get("example.org") + refute store.set("example.org", "s3cr3t") + refute store.delete("example.org") + end + + def test_get_swallows_backend_errors_and_returns_nil + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + + assert_nil store.get("example.org") + end + + def test_set_refuses_a_value_no_backend_can_round_trip + # The macOS keychain returns non-printable bytes as hex through the only + # read-back its CLI offers, so the rule applies to every backend and the + # caller falls back to the file rather than storing something unreadable. + backend = FakeBackend.new + store = Gem::CredentialStore.new(backend: backend) + + use_ui(@ui) do + assert_equal false, store.set("example.org", "p\u00e9\u3042") + assert_equal false, store.set("example.org", "line1\nline2") + assert_equal false, store.set("acct\nadd-generic-password", "secret") + end + + assert_nil backend.get("rubygems", "example.org") + assert_match(/Credential store write failed/, @ui.errs.string) + end + + def test_set_swallows_backend_errors_and_returns_false + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + + refute store.set("example.org", "s3cr3t") + end + + def test_delete_swallows_backend_errors_and_returns_false + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + + refute store.delete("example.org") + end + + def test_read_failed_distinguishes_an_unreadable_account_from_an_absent_one + store = Gem::CredentialStore.new(backend: Gem::FakeCredentialBackend.new) + + use_ui(@ui) { assert_nil store.get("absent.example") } + refute store.read_failed?("absent.example") + + failing = Gem::CredentialStore.new(backend: RaisingBackend.new) + use_ui(@ui) { assert_nil failing.get("unreadable.example") } + + assert failing.read_failed?("unreadable.example") + end + + def test_a_successful_write_clears_the_read_failure + backend = SometimesRaisingBackend.new + store = Gem::CredentialStore.new(backend: backend) + + use_ui(@ui) { store.get("example.org") } + assert store.read_failed?("example.org") + + backend.raising = false + store.set("example.org", "s3cr3t") + + refute store.read_failed?("example.org") + end + + def test_repeated_failures_of_one_operation_warn_only_once + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + + use_ui(@ui) do + store.get("a") + store.get("b") + end + + assert_equal 1, @ui.errs.string.scan(/WARNING:/).length + end + + def test_each_operation_reports_its_own_outcome + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + + use_ui(@ui) do + store.get("a") + store.set("c", "x") + end + + # A failed write lands in the config file, a failed read does not come + # back with anything, so one warning cannot stand for both. + assert_match(/read failed .* any copy left in the config file/, @ui.errs.string) + assert_match(/write failed .* falling back to file storage/, @ui.errs.string) + end + + def test_list_returns_nil_when_the_backend_cannot_enumerate + backend = Class.new(Gem::FakeCredentialBackend) { undef_method :list }.new + store = Gem::CredentialStore.new(backend: backend) + store.set("example.org", "s3cr3t") + + # nil means "unknown", which callers must not confuse with "empty". + assert_nil store.list + end + + def test_list_returns_the_accounts_when_the_backend_can_enumerate + store = Gem::CredentialStore.new(backend: Gem::FakeCredentialBackend.new) + store.set("example.org", "s3cr3t") + store.set("other.example", "other") + + assert_equal ["example.org", "other.example"], store.list + end + + def test_warn_handler_receives_the_message_instead_of_gem_ui + received = [] + Gem::CredentialStore.warn_handler = ->(message) { received << message } + + use_ui(@ui) do + Gem::CredentialStore.warn_once "routed elsewhere" + end + + assert_equal ["routed elsewhere"], received + refute_match(/routed elsewhere/, @ui.errs.string) + end + + def test_different_warnings_are_each_emitted + use_ui(@ui) do + Gem::CredentialStore.warn_once "first problem" + Gem::CredentialStore.warn_once "first problem" + # A warning about one problem must not suppress a different one, or a + # plain text fallback would go unreported after any earlier warning. + Gem::CredentialStore.warn_once "second problem" + end + + assert_equal 2, @ui.errs.string.scan(/WARNING:/).length + end + + def test_instance_returns_the_same_object + assert_same Gem::CredentialStore.instance, Gem::CredentialStore.instance + end + + def test_delete_all_clears_the_service + backend = FakeBackend.new + backend.set(Gem::CredentialStore::SERVICE_NAME, "acct", "s") + store = Gem::CredentialStore.new(backend: backend) + + assert store.delete_all + assert_nil backend.get(Gem::CredentialStore::SERVICE_NAME, "acct") + end + + def test_delete_all_without_backend_is_safe + store = Gem::CredentialStore.new(backend: nil) + refute store.delete_all + end + + def test_delete_all_swallows_backend_errors + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + refute store.delete_all + end + + def test_delete_all_only_removes_its_own_service + backend = FakeBackend.new + Gem::CredentialStore.backend = backend + gem_store = Gem::CredentialStore.for(true, service: "rubygems") + bundler_store = Gem::CredentialStore.for(true, service: "bundler") + gem_store.set("acct", "gem-key") + bundler_store.set("gems.example.com", "user:pass") + + gem_store.delete_all + + assert_nil gem_store.get("acct") + assert_equal "user:pass", bundler_store.get("gems.example.com") + end + + def test_for_returns_nil_when_disabled + assert_nil Gem::CredentialStore.for(false) + assert_nil Gem::CredentialStore.for(nil) + end + + def test_for_memoizes_per_spec + Gem::CredentialStore.register_backend("faux", FakeBackend.new) + + assert_same Gem::CredentialStore.for("faux"), Gem::CredentialStore.for("faux") + end + + def test_register_and_resolve_backend_roundtrip + backend = FakeBackend.new + Gem::CredentialStore.register_backend("faux", backend) + + assert_same backend, Gem::CredentialStore.resolve_backend("faux") + + store = Gem::CredentialStore.for("faux") + assert store.set("example.org", "s3cr3t") + assert_equal "s3cr3t", store.get("example.org") + assert_includes backend.calls, [:set, Gem::CredentialStore::SERVICE_NAME, "example.org", "s3cr3t"] + end + + def test_resolve_backend_rejects_invalid_name + use_ui(@ui) do + assert_nil Gem::CredentialStore.resolve_backend("../evil") + assert_nil Gem::CredentialStore.resolve_backend("Foo Bar") + end + + assert_match(/invalid credential store backend name/, @ui.errs.string) + end + + def test_resolve_backend_rejects_an_undecodable_name_without_raising + # The setting can carry any bytes the environment hands over, and every + # public method on this class promises to warn rather than raise. + use_ui(@ui) do + assert_nil Gem::CredentialStore.resolve_backend("\xff".dup.force_encoding("UTF-8")) + end + + assert_match(/invalid credential store backend name/, @ui.errs.string) + end + + def test_resolve_backend_requires_convention_path_and_registers + backends_dir = File.join(@tempdir, "rubygems", "credential_store", "backends") + FileUtils.mkdir_p(backends_dir) + File.write(File.join(backends_dir, "faux_ext.rb"), <<~RUBY) + Gem::CredentialStore.register_backend("faux_ext", Object.new) + RUBY + + $LOAD_PATH.unshift(@tempdir) + + refute_nil Gem::CredentialStore.resolve_backend("faux_ext") + ensure + $LOAD_PATH.delete(@tempdir) + end + + def test_resolve_backend_unknown_name_returns_nil_and_warns + use_ui(@ui) do + assert_nil Gem::CredentialStore.resolve_backend("definitely_not_installed_xyz") + end + + assert_match(/is not installed/, @ui.errs.string) + end + + def test_instance_override_wins_for_any_enabled_spec + fake = Gem::CredentialStore.new(backend: FakeBackend.new) + Gem::CredentialStore.instance = fake + + assert_same fake, Gem::CredentialStore.for(true) + assert_same fake, Gem::CredentialStore.for("1password") + end +end diff --git a/test/rubygems/test_gem_credential_store_linux_backend.rb b/test/rubygems/test_gem_credential_store_linux_backend.rb new file mode 100644 index 00000000000000..f00c54535f91f2 --- /dev/null +++ b/test/rubygems/test_gem_credential_store_linux_backend.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store/native/linux" +require "json" + +class TestGemCredentialStoreLinuxBackend < Gem::TestCase + FAKE_COMMAND = <<~'RUBY' + #!/usr/bin/env ruby + require "json" + stdin_content = $stdin.read + if record_path = ENV["RUBYGEMS_FAKE_CMD_RECORD"] + calls = File.exist?(record_path) ? JSON.parse(File.read(record_path)) : [] + calls << {"argv" => ARGV, "stdin" => stdin_content} + File.write(record_path, calls.to_json) + end + # A clear is followed by a search confirming nothing is left. secret-tool + # exits zero from a search whether or not it matched, and reports what it + # found on stderr, so the fake answers that call separately. + if ARGV.first == "search" && ENV.key?("RUBYGEMS_FAKE_CMD_REMAINING") + $stderr.write(ENV["RUBYGEMS_FAKE_CMD_REMAINING"].to_s) + exit(0) + end + $stdout.write(ENV["RUBYGEMS_FAKE_CMD_STDOUT"].to_s) + $stderr.write(ENV["RUBYGEMS_FAKE_CMD_STDERR"].to_s) + exit(ENV["RUBYGEMS_FAKE_CMD_EXIT"].to_i) + RUBY + + def setup + super + pend "fake shebang executables aren't supported on native Windows" if Gem.win_platform? + + @fake_bin_dir = File.join(@tempdir, "fake-bin") + FileUtils.mkdir_p(@fake_bin_dir) + fake_path = File.join(@fake_bin_dir, "secret-tool") + File.write(fake_path, FAKE_COMMAND) + File.chmod(0o755, fake_path) + + @record_path = File.join(@tempdir, "record.json") + Gem::CredentialStore::LinuxBackend.reset! + end + + def teardown + Gem::CredentialStore::LinuxBackend.reset! + super + end + + def test_available_is_true_when_secret_tool_is_on_path + with_env(ENV.to_h.merge("PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR))) do + Gem::CredentialStore::LinuxBackend.reset! + assert Gem::CredentialStore::LinuxBackend.available? + end + end + + def test_available_is_false_when_secret_tool_is_missing + empty_dir = File.join(@tempdir, "empty-bin") + FileUtils.mkdir_p(empty_dir) + + with_env(ENV.to_h.merge("PATH" => empty_dir)) do + Gem::CredentialStore::LinuxBackend.reset! + refute Gem::CredentialStore::LinuxBackend.available? + end + end + + def test_get_returns_stripped_secret_on_success + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + assert_equal "s3cr3t", Gem::CredentialStore::LinuxBackend.get("rubygems", "example.org") + end + end + + def test_get_returns_nil_when_not_found + with_fake_env(stdout: "", exit: 1) do + assert_nil Gem::CredentialStore::LinuxBackend.get("rubygems", "example.org") + end + end + + def test_get_raises_when_the_keyring_reports_an_error + # An absent entry exits 1 with nothing on stderr. Anything else is a real + # failure and must not read as "no credential stored". + with_fake_env(stderr: "unexpected D-Bus error", exit: 1) do + error = assert_raise(RuntimeError) do + Gem::CredentialStore::LinuxBackend.get("rubygems", "example.org") + end + + assert_match(/D-Bus/, error.message) + end + end + + def test_get_uses_expected_argv + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + Gem::CredentialStore::LinuxBackend.get("rubygems", "example.org") + end + + record = read_record + assert_equal %w[lookup service rubygems account example.org], record["argv"] + end + + def test_set_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::LinuxBackend.set("rubygems", "example.org", "s3cr3t") + end + end + + def test_set_passes_secret_via_stdin_not_argv + with_fake_env(exit: 0) do + Gem::CredentialStore::LinuxBackend.set("rubygems", "example.org", "s3cr3t") + end + + record = read_record + refute_includes record["argv"], "s3cr3t" + assert_equal "s3cr3t", record["stdin"] + end + + def test_list_reads_the_account_attribute_from_stderr + # secret-tool prints attributes to stderr and the secrets to stdout. + attributes = <<~ERR + attribute.service = bundler + attribute.account = gems.example.com + attribute.service = bundler + attribute.account = other.example.org + ERR + + with_fake_env(stdout: "username:password", stderr: attributes, exit: 0) do + assert_equal ["gems.example.com", "other.example.org"], + Gem::CredentialStore::LinuxBackend.list("bundler") + end + + assert_equal %w[search --all service bundler], read_record["argv"] + end + + def test_list_ignores_attribute_lines_planted_inside_a_secret + # A secret is free-form and lands on stdout, so a newline inside it must + # not be able to add an account to the listing. + planted = "u:p\nattribute.account = injected\n" + + with_fake_env(stdout: planted, stderr: "attribute.account = real.example.com\n", exit: 0) do + assert_equal ["real.example.com"], Gem::CredentialStore::LinuxBackend.list("bundler") + end + end + + def test_list_returns_empty_on_failure + with_fake_env(exit: 1) do + assert_empty Gem::CredentialStore::LinuxBackend.list("bundler") + end + end + + def test_delete_returns_true_on_success + with_fake_env(exit: 0, remaining: "") do + assert Gem::CredentialStore::LinuxBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_true_when_nothing_matched + with_fake_env(stderr: "", exit: 1) do + assert Gem::CredentialStore::LinuxBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_false_on_other_failure + with_fake_env(stderr: "unexpected D-Bus error", exit: 1) do + refute Gem::CredentialStore::LinuxBackend.delete("rubygems", "example.org") + end + end + + def test_delete_all_clears_by_service_only + with_fake_env(exit: 0, remaining: "") do + assert Gem::CredentialStore::LinuxBackend.delete_all("rubygems") + end + + record = read_record + assert_equal %w[clear service rubygems], record["argv"] + end + + def test_delete_all_reports_failure_when_the_entries_survive + # libsecret skips locked items and still reports success, so a locked + # keyring would otherwise let signout claim it removed keys it kept. + survivor = "attribute.account = example.org\n" + + with_fake_env(exit: 0, remaining: survivor) do + refute Gem::CredentialStore::LinuxBackend.delete_all("rubygems") + refute Gem::CredentialStore::LinuxBackend.delete("rubygems", "example.org") + end + end + + def test_delete_reports_success_when_only_other_accounts_remain + with_fake_env(exit: 0, remaining: "attribute.account = other.example.org\n") do + assert Gem::CredentialStore::LinuxBackend.delete("rubygems", "example.org") + refute Gem::CredentialStore::LinuxBackend.delete_all("rubygems") + end + end + + def test_delete_all_returns_true_when_nothing_matched + with_fake_env(stderr: "", exit: 1) do + assert Gem::CredentialStore::LinuxBackend.delete_all("rubygems") + end + end + + def test_delete_all_returns_false_on_other_failure + with_fake_env(stderr: "unexpected D-Bus error", exit: 1) do + refute Gem::CredentialStore::LinuxBackend.delete_all("rubygems") + end + end + + private + + def with_fake_env(stdout: "", stderr: "", exit: 0, remaining: nil) + overrides = ENV.to_h.merge( + "PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR), + "RUBYGEMS_FAKE_CMD_STDOUT" => stdout, + "RUBYGEMS_FAKE_CMD_STDERR" => stderr, + "RUBYGEMS_FAKE_CMD_EXIT" => exit.to_s, + "RUBYGEMS_FAKE_CMD_REMAINING" => remaining, + "RUBYGEMS_FAKE_CMD_RECORD" => @record_path + ) + with_env(overrides) { yield } + end + + # The first call, so a delete still reports the clear it issued rather than + # the search that confirms the clear took effect. + def read_record + JSON.parse(File.read(@record_path)).first + end +end diff --git a/test/rubygems/test_gem_credential_store_macos_backend.rb b/test/rubygems/test_gem_credential_store_macos_backend.rb new file mode 100644 index 00000000000000..560f4c82be55ee --- /dev/null +++ b/test/rubygems/test_gem_credential_store_macos_backend.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store" +require "rubygems/credential_store/native/macos" +require "json" + +class TestGemCredentialStoreMacosBackend < Gem::TestCase + FAKE_COMMAND = <<~'RUBY' + #!/usr/bin/env ruby + require "json" + stdin_content = $stdin.read + if record_path = ENV["RUBYGEMS_FAKE_CMD_RECORD"] + File.write(record_path, {"argv" => ARGV, "stdin" => stdin_content}.to_json) + end + $stdout.write(ENV["RUBYGEMS_FAKE_CMD_STDOUT"].to_s) + $stderr.write(ENV["RUBYGEMS_FAKE_CMD_STDERR"].to_s) + exit(ENV["RUBYGEMS_FAKE_CMD_EXIT"].to_i) + RUBY + + def setup + super + pend "fake shebang executables aren't supported on native Windows" if Gem.win_platform? + + @fake_bin_dir = File.join(@tempdir, "fake-bin") + FileUtils.mkdir_p(@fake_bin_dir) + fake_path = File.join(@fake_bin_dir, "security") + File.write(fake_path, FAKE_COMMAND) + File.chmod(0o755, fake_path) + + @record_path = File.join(@tempdir, "record.json") + end + + def test_get_returns_stripped_secret_on_success + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + assert_equal "s3cr3t", Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + end + end + + def test_get_returns_nil_when_not_found + with_fake_env(stdout: "", exit: 44) do + assert_nil Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + end + end + + def test_get_raises_when_the_keychain_refuses + # A locked keychain is not an absent entry. Raising lets the wrapper say + # why the credential could not be read. + with_fake_env(stderr: "User interaction is not allowed.", exit: 51) do + error = assert_raise(RuntimeError) do + Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + end + + assert_match(/User interaction is not allowed/, error.message) + end + end + + def test_get_uses_expected_argv + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + end + + record = read_record + assert_equal %w[find-generic-password -a example.org -s rubygems -w], record["argv"] + end + + def test_set_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", "s3cr3t") + end + end + + def test_set_reports_why_it_failed + # The wrapper turns this into false; raising is how the reason reaches + # the user instead of an unexplained "could not write" message. + with_fake_env(stderr: "keychain is locked", exit: 1) do + error = assert_raise RuntimeError do + Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", "s3cr3t") + end + + assert_match(/keychain is locked/, error.message) + end + end + + def test_set_failure_becomes_false_through_the_store + with_fake_env(stderr: "keychain is locked", exit: 1) do + store = Gem::CredentialStore.new(backend: Gem::CredentialStore::MacOSBackend) + + refute store.set("example.org", "s3cr3t") + end + end + + def test_set_passes_secret_via_stdin_not_argv + with_fake_env(exit: 0) do + Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", "s3cr3t") + end + + record = read_record + refute_includes record["argv"], "s3cr3t" + assert_includes record["stdin"], "s3cr3t" + end + + def test_set_escapes_quotes_and_backslashes_in_the_stdin_command + with_fake_env(exit: 0) do + Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", %(pa"ss\\word)) + end + + record = read_record + assert_equal %(add-generic-password -U -a "example.org" -s "rubygems" -w "pa\\"ss\\\\word"\n), record["stdin"] + end + + def test_list_returns_accounts_for_the_service_only + dump = <<~DUMP + keychain: "/Users/x/Library/Keychains/login.keychain-db" + attributes: + "acct"="gems.example.com" + "svce"="bundler" + keychain: "/Users/x/Library/Keychains/login.keychain-db" + attributes: + "acct"="other.example.org" + "svce"="bundler" + keychain: "/Users/x/Library/Keychains/login.keychain-db" + attributes: + "acct"="unrelated" + "svce"="something-else" + DUMP + + with_fake_env(stdout: dump, exit: 0) do + assert_equal ["gems.example.com", "other.example.org"], + Gem::CredentialStore::MacOSBackend.list("bundler") + end + end + + def test_list_returns_empty_on_failure + with_fake_env(exit: 1) do + assert_empty Gem::CredentialStore::MacOSBackend.list("bundler") + end + end + + def test_delete_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::MacOSBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_true_when_not_found + with_fake_env(exit: 44) do + assert Gem::CredentialStore::MacOSBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_false_on_other_failure + with_fake_env(exit: 1) do + refute Gem::CredentialStore::MacOSBackend.delete("rubygems", "example.org") + end + end + + def test_delete_all_loops_until_not_found + # security deletes one entry per call; stub exits 0 twice, then 44. + counter = File.join(@tempdir, "counter") + File.write(counter, "0") + script = <<~RUBY + #!/usr/bin/env ruby + c = File.read(#{counter.inspect}).to_i + File.write(#{counter.inspect}, (c + 1).to_s) + exit(c < 2 ? 0 : 44) + RUBY + File.write(File.join(@fake_bin_dir, "security"), script) + File.chmod(0o755, File.join(@fake_bin_dir, "security")) + + with_env(ENV.to_h.merge("PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR))) do + assert Gem::CredentialStore::MacOSBackend.delete_all("rubygems") + end + + assert_equal 3, File.read(counter).to_i + end + + def test_delete_all_returns_false_on_error + with_fake_env(exit: 1) do + refute Gem::CredentialStore::MacOSBackend.delete_all("rubygems") + end + end + + def test_get_returns_no_credential_when_command_missing + empty_dir = File.join(@tempdir, "empty-bin") + FileUtils.mkdir_p(empty_dir) + + # A missing security binary must not yield a credential. MRI raises + # Errno::ENOENT from Open3; other implementations (JRuby) report a + # failure status instead of raising, so accept either and assert only + # that nothing is returned. Gem::CredentialStore#get traps the error + # class either way. + with_env(ENV.to_h.merge("PATH" => empty_dir)) do + result = + begin + Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + rescue StandardError + nil + end + + assert_nil result + end + end + + private + + def with_fake_env(stdout: "", stderr: "", exit: 0) + overrides = ENV.to_h.merge( + "PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR), + "RUBYGEMS_FAKE_CMD_STDOUT" => stdout, + "RUBYGEMS_FAKE_CMD_STDERR" => stderr, + "RUBYGEMS_FAKE_CMD_EXIT" => exit.to_s, + "RUBYGEMS_FAKE_CMD_RECORD" => @record_path + ) + with_env(overrides) { yield } + end + + def read_record + JSON.parse(File.read(@record_path)) + end +end diff --git a/test/rubygems/test_gem_credential_store_windows_backend.rb b/test/rubygems/test_gem_credential_store_windows_backend.rb new file mode 100644 index 00000000000000..0a8e75c33216ed --- /dev/null +++ b/test/rubygems/test_gem_credential_store_windows_backend.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store/native/windows" +require "json" + +class TestGemCredentialStoreWindowsBackend < Gem::TestCase + FAKE_COMMAND = <<~'RUBY' + #!/usr/bin/env ruby + require "json" + stdin_content = $stdin.read + if record_path = ENV["RUBYGEMS_FAKE_CMD_RECORD"] + record = { + "argv" => ARGV, + "stdin" => stdin_content, + "service_env" => ENV["RUBYGEMS_CRED_SERVICE"], + "account_env" => ENV["RUBYGEMS_CRED_ACCOUNT"], + "secret_env" => ENV["RUBYGEMS_CRED_SECRET"], + } + File.write(record_path, record.to_json) + end + $stdout.write(ENV["RUBYGEMS_FAKE_CMD_STDOUT"].to_s) + $stderr.write(ENV["RUBYGEMS_FAKE_CMD_STDERR"].to_s) + exit(ENV["RUBYGEMS_FAKE_CMD_EXIT"].to_i) + RUBY + + def setup + super + + # The stand-in below is reached through MRI's own PATH search, which walks + # every extension inside one directory before moving to the next and knows + # to run a batch file through a command line. JRuby spawns through Java, + # whose search appends only .exe, so it would reach the real powershell. + pend "the powershell stand-in relies on how MRI resolves a program name" if Gem.java_platform? + + @fake_bin_dir = File.join(@tempdir, "fake-bin") + FileUtils.mkdir_p(@fake_bin_dir) + fake_path = File.join(@fake_bin_dir, "powershell") + File.write(fake_path, FAKE_COMMAND) + File.chmod(0o755, fake_path) + + # A shebang does not make a file executable on Windows. PATHEXT finds the + # batch file for the extensionless name the backend spawns, and the batch + # file hands the script next to it to the running ruby. + if Gem.win_platform? + File.write("#{fake_path}.bat", <<~BATCH) + @ECHO OFF + @"#{Gem.ruby.tr("/", File::ALT_SEPARATOR || "/")}" "%~dpn0" %* + BATCH + end + + @record_path = File.join(@tempdir, "record.json") + end + + def test_get_returns_stripped_secret_on_success + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + assert_equal "s3cr3t", Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + end + + def test_get_returns_nil_when_credential_missing + with_fake_env(stderr: "Element not found. (Exception from HRESULT: 0x80070490)", exit: 1) do + assert_nil Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + end + + def test_get_raises_on_any_other_failure + # A vault that refuses to answer is not the same as one holding nothing. + # Raising lets the wrapper report why rather than authenticating with no + # credential at all. + with_fake_env(stderr: "Access is denied.", exit: 1) do + error = assert_raise(RuntimeError) do + Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + + assert_match(/Access is denied/, error.message) + end + end + + def test_get_passes_service_and_account_via_environment_not_script + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + + record = read_record + assert_equal "rubygems", record["service_env"] + assert_equal "example.org", record["account_env"] + end + + def test_set_passes_secret_via_environment_not_argv_or_script_text + with_fake_env(exit: 0) do + Gem::CredentialStore::WindowsBackend.set("rubygems", "example.org", "s3cr3t") + end + + record = read_record + assert_equal "s3cr3t", record["secret_env"] + refute_includes record["argv"].to_s, "s3cr3t" + end + + def test_set_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::WindowsBackend.set("rubygems", "example.org", "s3cr3t") + end + end + + def test_list_returns_the_user_names + with_fake_env(stdout: "gems.example.com\nother.example.org\n", exit: 0) do + assert_equal ["gems.example.com", "other.example.org"], + Gem::CredentialStore::WindowsBackend.list("bundler") + end + + assert_equal "bundler", read_record["service_env"] + end + + def test_list_returns_empty_on_failure + with_fake_env(stderr: "Access is denied.", exit: 1) do + assert_empty Gem::CredentialStore::WindowsBackend.list("bundler") + end + end + + def test_delete_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::WindowsBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_true_when_credential_missing + with_fake_env(stderr: "Element not found. (Exception from HRESULT: 0x80070490)", exit: 1) do + assert Gem::CredentialStore::WindowsBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_false_on_other_failure + with_fake_env(stderr: "Access is denied.", exit: 1) do + refute Gem::CredentialStore::WindowsBackend.delete("rubygems", "example.org") + end + end + + def test_delete_all_passes_service_and_succeeds + with_fake_env(exit: 0) do + assert Gem::CredentialStore::WindowsBackend.delete_all("rubygems") + end + + assert_equal "rubygems", read_record["service_env"] + end + + def test_delete_all_returns_true_when_resource_missing + with_fake_env(stderr: "Element not found. (Exception from HRESULT: 0x80070490)", exit: 1) do + assert Gem::CredentialStore::WindowsBackend.delete_all("rubygems") + end + end + + def test_delete_all_returns_false_on_other_failure + with_fake_env(stderr: "Access is denied.", exit: 1) do + refute Gem::CredentialStore::WindowsBackend.delete_all("rubygems") + end + end + + def test_uses_powershell_exe_not_pwsh + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + + # No assertion beyond "this succeeded": the fake binary is only + # discoverable under the literal name powershell.exe, so a pass here + # proves the backend invokes that name specifically. + assert File.exist?(@record_path) + end + + private + + def with_fake_env(stdout: "", stderr: "", exit: 0) + overrides = ENV.to_h.merge( + "PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR), + "RUBYGEMS_FAKE_CMD_STDOUT" => stdout, + "RUBYGEMS_FAKE_CMD_STDERR" => stderr, + "RUBYGEMS_FAKE_CMD_EXIT" => exit.to_s, + "RUBYGEMS_FAKE_CMD_RECORD" => @record_path + ) + with_env(overrides) { yield } + end + + def read_record + JSON.parse(File.read(@record_path)) + end +end diff --git a/test/rubygems/test_gem_ext_builder.rb b/test/rubygems/test_gem_ext_builder.rb index 040877338bb37d..8f90687ede306f 100644 --- a/test/rubygems/test_gem_ext_builder.rb +++ b/test/rubygems/test_gem_ext_builder.rb @@ -194,6 +194,24 @@ def test_class_run_closes_stdin assert_equal "STDIN: \"\"\n", command_output end + def test_class_run_removes_ruby_box_from_env + ENV["RUBY_BOX"] = "1" + + results = [] + + Gem::Ext::Builder.run([Gem.ruby, "-e", 'puts ENV.key?("RUBY_BOX")'], results) + + assert_equal "false\n", results.last + end + + def test_class_run_removes_ruby_box_passed_by_caller + results = [] + + Gem::Ext::Builder.run([Gem.ruby, "-e", 'puts ENV.key?("RUBY_BOX")'], results, nil, Dir.pwd, { "RUBY_BOX" => "1" }) + + assert_equal "false\n", results.last + end + def test_build_extensions pend "terminates on mswin" if vc_windows? && ruby_repo? diff --git a/test/rubygems/test_gem_gemcutter_utilities.rb b/test/rubygems/test_gem_gemcutter_utilities.rb index ca34c8d03dc73a..98a697e2b09b02 100644 --- a/test/rubygems/test_gem_gemcutter_utilities.rb +++ b/test/rubygems/test_gem_gemcutter_utilities.rb @@ -6,6 +6,7 @@ require "rubygems/command" require "rubygems/gemcutter_utilities" require "rubygems/config_file" +require "rubygems/credential_store" class TestGemGemcutterUtilities < Gem::TestCase def setup @@ -35,6 +36,31 @@ def teardown super end + def test_key_option_does_not_reach_a_hosts_stored_key + # --key names a key, the store is keyed by host. Letting one resolve the + # other would hand a host's key to whatever host is being pushed to. + Gem.configuration.credential_store = true + + with_fake_credential_store do + Gem.configuration.set_api_key "https://internal.example.com", "internal-key" + + @cmd = Gem::Command.new "dummy", "dummy" + @cmd.extend Gem::GemcutterUtilities + @cmd.options[:key] = :"https://internal.example.com" + @cmd.host = "https://public.example.com" + + use_ui @ui do + assert_raise Gem::MockGemUi::TermError do + @cmd.api_key + end + end + + assert_match(/No such API key/, @ui.error) + end + ensure + Gem.configuration.credential_store = false + end + def test_alternate_key_alternate_host keys = { :rubygems_api_key => "KEY", @@ -52,6 +78,199 @@ def test_alternate_key_alternate_host assert_equal "EYKEY", @cmd.api_key end + def test_api_key_from_credential_store_takes_precedence_over_file + Gem.configuration.credential_store = true + + with_fake_credential_store do |store| + keys = { rubygems_api_key: "FILE-KEY" } + + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml(keys) + end + + Gem.configuration.load_api_keys + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "CREDENTIAL_STORE-KEY") + + assert_equal "CREDENTIAL_STORE-KEY", @cmd.api_key + end + ensure + Gem.configuration.credential_store = false + end + + def unreadable_credential_store + backend = Class.new(Gem::FakeCredentialBackend) do + def get(_service, _account) + raise Errno::ENOENT, "security" + end + end.new + + Gem::CredentialStore.new(backend: backend) + end + + def test_api_key_refuses_to_fall_back_when_the_hosts_key_cannot_be_read + Gem.configuration.credential_store = true + + # Nothing here belongs to the third-party host, and the store will not say + # whether it holds anything. Falling through to the RubyGems.org key would + # hand it to that host. + Gem::CredentialStore.instance = unreadable_credential_store + + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml({ rubygems_api_key: "RUBYGEMS-ORG-KEY" }) + end + Gem.configuration.load_api_keys + + ENV["RUBYGEMS_HOST"] = "http://rubygems.engineyard.com" + + assert_raise Gem::MockGemUi::TermError do + use_ui(@ui) { @cmd.api_key } + end + + assert_match(%r{no API key for http://rubygems\.engineyard\.com could be found}, @ui.error) + refute_match(/RUBYGEMS-ORG-KEY/, @ui.error) + ensure + Gem::CredentialStore.reset! + Gem.configuration.credential_store = false + end + + def test_api_key_uses_the_hosts_file_key_when_the_store_cannot_be_read + Gem.configuration.credential_store = true + + # The store is unreadable but this host has its own key on disk. That key + # cannot leak anywhere, so there is nothing to stop for. + Gem::CredentialStore.instance = unreadable_credential_store + + keys = { + :rubygems_api_key => "RUBYGEMS-ORG-KEY", + "http://rubygems.engineyard.com" => "EYKEY", + } + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml(keys) + end + Gem.configuration.load_api_keys + + ENV["RUBYGEMS_HOST"] = "http://rubygems.engineyard.com" + + use_ui(@ui) { assert_equal "EYKEY", @cmd.api_key } + ensure + Gem::CredentialStore.reset! + Gem.configuration.credential_store = false + end + + def test_api_key_uses_the_default_hosts_file_key_when_the_store_cannot_be_read + Gem.configuration.credential_store = true + + # The key is still in the credentials file, so the unreadable store cost + # nothing. Stopping here would make every plain `gem push` fail. + Gem::CredentialStore.instance = unreadable_credential_store + + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml({ rubygems_api_key: "RUBYGEMS-ORG-KEY" }) + end + Gem.configuration.load_api_keys + + use_ui(@ui) { assert_equal "RUBYGEMS-ORG-KEY", @cmd.api_key } + ensure + Gem::CredentialStore.reset! + Gem.configuration.credential_store = false + end + + def test_api_key_notices_a_failure_under_the_default_account_alone + Gem.configuration.credential_store = true + + # Nothing is ever written under the default host's own name, so a read + # there finds nothing and records no failure. The key lives under the + # default account, and a refusal to read that one is the only signal + # there will be. + selective = Class.new(Gem::FakeCredentialBackend) do + def get(service, account) + raise Errno::ENOENT, "security" if account == Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT + + super + end + end.new + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: selective) + + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml({}) + end + Gem.configuration.load_api_keys + + assert_raise Gem::MockGemUi::TermError do + use_ui(@ui) { @cmd.api_key } + end + ensure + Gem::CredentialStore.reset! + Gem.configuration.credential_store = false + end + + def test_api_key_stops_rather_than_asking_for_a_password_when_the_store_is_all_there_is + Gem.configuration.credential_store = true + + # The migrated case: the key lives only in the store, which will not + # answer. Returning nil here reads as "not signed in" and sends the user + # to a password prompt, downgrading key authentication to the account + # password over a locked keychain. + Gem::CredentialStore.instance = unreadable_credential_store + + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml({}) + end + Gem.configuration.load_api_keys + + assert_raise Gem::MockGemUi::TermError do + use_ui(@ui) { @cmd.api_key } + end + + assert_match(/credential store could not be read/, @ui.error) + ensure + Gem::CredentialStore.reset! + Gem.configuration.credential_store = false + end + + def test_api_key_prefers_the_hosts_file_key_over_the_stored_default_key + Gem.configuration.credential_store = true + + with_fake_credential_store do |store| + keys = { + :rubygems_api_key => "FILE-DEFAULT-KEY", + "http://rubygems.engineyard.com" => "EYKEY", + } + + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml(keys) + end + + ENV["RUBYGEMS_HOST"] = "http://rubygems.engineyard.com" + Gem.configuration.load_api_keys + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "RUBYGEMS-ORG-KEY") + + # Sending RUBYGEMS-ORG-KEY here would hand the RubyGems.org key to a + # third-party host that has a key of its own. + assert_equal "EYKEY", @cmd.api_key + end + ensure + Gem.configuration.credential_store = false + end + + def test_api_key_falls_back_to_file_when_no_credential_store_entry + Gem.configuration.credential_store = true + + with_fake_credential_store do + keys = { rubygems_api_key: "FILE-KEY" } + + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml(keys) + end + + Gem.configuration.load_api_keys + + assert_equal "FILE-KEY", @cmd.api_key + end + ensure + Gem.configuration.credential_store = false + end + def test_api_key keys = { rubygems_api_key: "KEY" } diff --git a/test/rubygems/test_gem_util.rb b/test/rubygems/test_gem_util.rb index 2270ac08b0f15b..6b7622b8172449 100644 --- a/test/rubygems/test_gem_util.rb +++ b/test/rubygems/test_gem_util.rb @@ -83,4 +83,42 @@ def test_correct_for_windows_path path = "/home/skillet" assert_equal "/home/skillet", Gem::Util.correct_for_windows_path(path) end + + def test_deep_dup + defaults = { + args: ["--local", +"extra"], + document: %w[ri], + nested: { list: [+"a", { key: +"b" }] }, + version: Gem::Requirement.default, + wrappers: true, + count: 1, + sym: :install, + } + + copy = Gem::Util.deep_dup defaults + + assert_equal defaults, copy + refute_same defaults, copy + refute_same defaults[:args], copy[:args] + refute_same defaults[:args][1], copy[:args][1] + refute_same defaults[:nested], copy[:nested] + refute_same defaults[:nested][:list], copy[:nested][:list] + refute_same defaults[:nested][:list][1], copy[:nested][:list][1] + refute_same defaults[:version], copy[:version] + + copy[:args] << "added" + copy[:nested][:list][1][:key] << "!" + + assert_equal ["--local", "extra"], defaults[:args] + assert_equal "b", defaults[:nested][:list][1][:key] + end + + def test_deep_dup_shares_the_internals_of_other_objects + struct = Struct.new(:list).new([+"a"]) + + copy = Gem::Util.deep_dup(struct: struct)[:struct] + + refute_same struct, copy + assert_same struct.list, copy.list + end end diff --git a/yjit/src/cruby_bindings.inc.rs b/yjit/src/cruby_bindings.inc.rs index e00aec76e277e9..7bfb386c907d88 100644 --- a/yjit/src/cruby_bindings.inc.rs +++ b/yjit/src/cruby_bindings.inc.rs @@ -765,6 +765,7 @@ pub const RHASH_AR_TABLE_SIZE_MASK: ruby_rhash_flags = 983040; pub const RHASH_AR_TABLE_SIZE_SHIFT: ruby_rhash_flags = 16; pub const RHASH_AR_TABLE_BOUND_MASK: ruby_rhash_flags = 15728640; pub const RHASH_AR_TABLE_BOUND_SHIFT: ruby_rhash_flags = 20; +pub const RHASH_COMPARE_BY_IDENTITY: ruby_rhash_flags = 16777216; pub const RHASH_LEV_SHIFT: ruby_rhash_flags = 25; pub const RHASH_LEV_MAX: ruby_rhash_flags = 127; pub type ruby_rhash_flags = u32; diff --git a/zjit/src/codegen_tests.rs b/zjit/src/codegen_tests.rs index e51fdfc8093908..79ff8dd539780a 100644 --- a/zjit/src/codegen_tests.rs +++ b/zjit/src/codegen_tests.rs @@ -2863,6 +2863,29 @@ fn test_opt_minus_overflow() { "), @"[2, 4611686018427387904, -4611686018427387905]"); } +#[test] +fn test_fixnum_lshift() { + assert_snapshot!(inspect(" + def test(a) = a << 3 + test(1) # profile opt_ltlt + + [test(5), test(0), test(-5)] + "), @"[40, 0, -40]"); +} + +#[test] +fn test_fixnum_lshift_overflow() { + assert_snapshot!(inspect(" + def test(a) = a << 3 + test(1) # profile opt_ltlt + + r1 = test(1 << 60) + r2 = test(-(1 << 60)) + + [r1, r2] + "), @"[9223372036854775808, -9223372036854775808]"); +} + #[test] fn test_opt_eq() { eval(" diff --git a/zjit/src/cruby_bindings.inc.rs b/zjit/src/cruby_bindings.inc.rs index f85c74860356f0..a1b6da8e51c20f 100644 --- a/zjit/src/cruby_bindings.inc.rs +++ b/zjit/src/cruby_bindings.inc.rs @@ -1735,6 +1735,7 @@ pub const RHASH_AR_TABLE_SIZE_MASK: ruby_rhash_flags = 983040; pub const RHASH_AR_TABLE_SIZE_SHIFT: ruby_rhash_flags = 16; pub const RHASH_AR_TABLE_BOUND_MASK: ruby_rhash_flags = 15728640; pub const RHASH_AR_TABLE_BOUND_SHIFT: ruby_rhash_flags = 20; +pub const RHASH_COMPARE_BY_IDENTITY: ruby_rhash_flags = 16777216; pub const RHASH_LEV_SHIFT: ruby_rhash_flags = 25; pub const RHASH_LEV_MAX: ruby_rhash_flags = 127; pub type ruby_rhash_flags = u32;