bundler 4.0.21 → 4.1.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. checksums.yaml +4 -4
  2. data/{lib/bundler/vendor/connection_pool/LICENSE → MIT.txt} +8 -6
  3. data/bundler.gemspec +23 -6
  4. data/lib/bundler/build_metadata.rb +2 -2
  5. data/lib/bundler/checksum.rb +40 -25
  6. data/lib/bundler/ci_detector.rb +1 -1
  7. data/lib/bundler/cli/add.rb +3 -4
  8. data/lib/bundler/cli/cache.rb +4 -0
  9. data/lib/bundler/cli/clean.rb +5 -0
  10. data/lib/bundler/cli/common.rb +37 -1
  11. data/lib/bundler/cli/config.rb +4 -2
  12. data/lib/bundler/cli/gem.rb +0 -17
  13. data/lib/bundler/cli/install.rb +4 -5
  14. data/lib/bundler/cli/lock.rb +1 -2
  15. data/lib/bundler/cli/outdated.rb +8 -3
  16. data/lib/bundler/cli/pristine.rb +2 -1
  17. data/lib/bundler/cli/update.rb +21 -12
  18. data/lib/bundler/cli.rb +14 -11
  19. data/lib/bundler/definition.rb +61 -24
  20. data/lib/bundler/dependency.rb +4 -0
  21. data/lib/bundler/dsl.rb +77 -11
  22. data/lib/bundler/endpoint_specification.rb +51 -14
  23. data/lib/bundler/env.rb +7 -24
  24. data/lib/bundler/errors.rb +34 -5
  25. data/lib/bundler/fetcher/compact_index.rb +14 -5
  26. data/lib/bundler/fetcher/connection_pools.rb +150 -0
  27. data/lib/bundler/fetcher/downloader.rb +31 -16
  28. data/lib/bundler/fetcher.rb +8 -47
  29. data/lib/bundler/injector.rb +3 -3
  30. data/lib/bundler/inline.rb +44 -4
  31. data/lib/bundler/installer/parallel_installer.rb +25 -30
  32. data/lib/bundler/installer.rb +20 -6
  33. data/lib/bundler/lazy_specification.rb +44 -17
  34. data/lib/bundler/lockfile_generator.rb +24 -2
  35. data/lib/bundler/lockfile_parser.rb +43 -5
  36. data/lib/bundler/man/bundle-add.1 +7 -4
  37. data/lib/bundler/man/bundle-add.1.ronn +8 -4
  38. data/lib/bundler/man/bundle-binstubs.1 +1 -1
  39. data/lib/bundler/man/bundle-cache.1 +2 -2
  40. data/lib/bundler/man/bundle-cache.1.ronn +2 -2
  41. data/lib/bundler/man/bundle-check.1 +1 -1
  42. data/lib/bundler/man/bundle-clean.1 +1 -1
  43. data/lib/bundler/man/bundle-config.1 +38 -6
  44. data/lib/bundler/man/bundle-config.1.ronn +143 -10
  45. data/lib/bundler/man/bundle-console.1 +1 -1
  46. data/lib/bundler/man/bundle-doctor.1 +1 -1
  47. data/lib/bundler/man/bundle-env.1 +1 -1
  48. data/lib/bundler/man/bundle-exec.1 +1 -1
  49. data/lib/bundler/man/bundle-fund.1 +1 -1
  50. data/lib/bundler/man/bundle-gem.1 +1 -1
  51. data/lib/bundler/man/bundle-help.1 +1 -1
  52. data/lib/bundler/man/bundle-info.1 +1 -1
  53. data/lib/bundler/man/bundle-init.1 +1 -1
  54. data/lib/bundler/man/bundle-install.1 +2 -2
  55. data/lib/bundler/man/bundle-install.1.ronn +4 -4
  56. data/lib/bundler/man/bundle-issue.1 +1 -1
  57. data/lib/bundler/man/bundle-licenses.1 +1 -1
  58. data/lib/bundler/man/bundle-list.1 +1 -1
  59. data/lib/bundler/man/bundle-lock.1 +3 -3
  60. data/lib/bundler/man/bundle-lock.1.ronn +5 -5
  61. data/lib/bundler/man/bundle-open.1 +1 -1
  62. data/lib/bundler/man/bundle-outdated.1 +1 -1
  63. data/lib/bundler/man/bundle-platform.1 +1 -1
  64. data/lib/bundler/man/bundle-plugin.1 +1 -1
  65. data/lib/bundler/man/bundle-pristine.1 +1 -1
  66. data/lib/bundler/man/bundle-remove.1 +1 -1
  67. data/lib/bundler/man/bundle-show.1 +1 -1
  68. data/lib/bundler/man/bundle-update.1 +3 -3
  69. data/lib/bundler/man/bundle-update.1.ronn +3 -5
  70. data/lib/bundler/man/bundle-version.1 +1 -1
  71. data/lib/bundler/man/bundle.1 +1 -1
  72. data/lib/bundler/man/gemfile.5 +45 -1
  73. data/lib/bundler/man/gemfile.5.ronn +53 -0
  74. data/lib/bundler/match_metadata.rb +20 -0
  75. data/lib/bundler/match_platform.rb +25 -2
  76. data/lib/bundler/match_remote_metadata.rb +21 -6
  77. data/lib/bundler/materialization.rb +2 -1
  78. data/lib/bundler/override.rb +69 -0
  79. data/lib/bundler/plugin/dsl.rb +3 -5
  80. data/lib/bundler/plugin/events.rb +55 -13
  81. data/lib/bundler/plugin/index.rb +87 -11
  82. data/lib/bundler/plugin/installer/path.rb +1 -1
  83. data/lib/bundler/plugin/installer.rb +13 -5
  84. data/lib/bundler/plugin.rb +58 -30
  85. data/lib/bundler/remote_specification.rb +6 -3
  86. data/lib/bundler/resolver/base.rb +2 -1
  87. data/lib/bundler/resolver/incompatibility.rb +1 -1
  88. data/lib/bundler/resolver.rb +73 -23
  89. data/lib/bundler/ruby_version.rb +0 -2
  90. data/lib/bundler/rubygems_ext.rb +71 -1
  91. data/lib/bundler/rubygems_gem_installer.rb +41 -2
  92. data/lib/bundler/rubygems_integration.rb +7 -2
  93. data/lib/bundler/runtime.rb +69 -2
  94. data/lib/bundler/self_manager.rb +9 -15
  95. data/lib/bundler/settings.rb +373 -30
  96. data/lib/bundler/shared_helpers.rb +2 -1
  97. data/lib/bundler/source/git/git_proxy.rb +2 -10
  98. data/lib/bundler/source/git.rb +15 -6
  99. data/lib/bundler/source/path/installer.rb +1 -1
  100. data/lib/bundler/source/rubygems/remote.rb +10 -5
  101. data/lib/bundler/source/rubygems.rb +48 -12
  102. data/lib/bundler/spec_set.rb +16 -4
  103. data/lib/bundler/stub_specification.rb +2 -1
  104. data/lib/bundler/templates/newgem/Gemfile.tt +4 -4
  105. data/lib/bundler/templates/newgem/newgem.gemspec.tt +3 -3
  106. data/lib/bundler/templates/newgem/spec/newgem_spec.rb.tt +1 -1
  107. data/lib/bundler/templates/newgem/test/minitest/test_newgem.rb.tt +1 -1
  108. data/lib/bundler/ui/shell.rb +0 -4
  109. data/lib/bundler/ui/silent.rb +0 -4
  110. data/lib/bundler/vendor/thor/lib/thor/actions/inject_into_file.rb +35 -2
  111. data/lib/bundler/vendor/thor/lib/thor/base.rb +2 -1
  112. data/lib/bundler/vendor/thor/lib/thor/shell/basic.rb +14 -6
  113. data/lib/bundler/vendor/thor/lib/thor/shell/color.rb +2 -0
  114. data/lib/bundler/vendor/thor/lib/thor/version.rb +1 -1
  115. data/lib/bundler/vendor/thor/lib/thor.rb +34 -1
  116. data/lib/bundler/vendored_pub_grub.rb +5 -2
  117. data/lib/bundler/vendored_securerandom.rb +11 -8
  118. data/lib/bundler/vendored_uri.rb +14 -14
  119. data/lib/bundler/version.rb +1 -1
  120. data/lib/bundler/worker.rb +3 -2
  121. data/lib/bundler.rb +1 -1
  122. data/lib/rubygems/compact_index_client/cache.rb +122 -0
  123. data/lib/rubygems/compact_index_client/cache_file.rb +147 -0
  124. data/lib/rubygems/compact_index_client/http_fetcher.rb +88 -0
  125. data/lib/rubygems/compact_index_client/parser.rb +90 -0
  126. data/lib/rubygems/compact_index_client/updater.rb +113 -0
  127. data/lib/rubygems/compact_index_client.rb +111 -0
  128. data/lib/rubygems/cooldown_settings.rb +57 -0
  129. data/lib/rubygems/credential_store/native/linux.rb +97 -0
  130. data/lib/rubygems/credential_store/native/macos.rb +88 -0
  131. data/lib/rubygems/credential_store/native/windows.rb +130 -0
  132. data/lib/rubygems/credential_store.rb +332 -0
  133. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/assignment.rb +1 -1
  134. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/basic_package_source.rb +7 -7
  135. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/failure_writer.rb +1 -1
  136. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/incompatibility.rb +4 -4
  137. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/package.rb +1 -1
  138. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/partial_solution.rb +1 -1
  139. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/rubygems.rb +3 -3
  140. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/solve_failure.rb +1 -1
  141. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/static_package_source.rb +3 -3
  142. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/strategy.rb +2 -2
  143. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/term.rb +1 -1
  144. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/version.rb +1 -1
  145. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/version_constraint.rb +3 -3
  146. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/version_range.rb +1 -1
  147. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/version_solver.rb +2 -2
  148. data/lib/{bundler → rubygems}/vendor/pub_grub/lib/pub_grub/version_union.rb +1 -1
  149. data/lib/rubygems/vendor/pub_grub/lib/pub_grub.rb +57 -0
  150. data/lib/{bundler → rubygems}/vendor/securerandom/lib/securerandom.rb +10 -6
  151. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/common.rb +119 -119
  152. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/file.rb +13 -13
  153. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/ftp.rb +24 -24
  154. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/generic.rb +132 -132
  155. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/http.rb +18 -18
  156. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/https.rb +4 -4
  157. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/ldap.rb +14 -14
  158. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/ldaps.rb +4 -4
  159. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/mailto.rb +21 -21
  160. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/rfc2396_parser.rb +39 -39
  161. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/rfc3986_parser.rb +14 -14
  162. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/version.rb +1 -1
  163. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/ws.rb +12 -12
  164. data/lib/{bundler → rubygems}/vendor/uri/lib/uri/wss.rb +4 -4
  165. data/lib/{bundler → rubygems}/vendor/uri/lib/uri.rb +35 -31
  166. data/lib/rubygems/yaml_serializer.rb +880 -0
  167. metadata +77 -68
  168. data/CHANGELOG.md +0 -5784
  169. data/LICENSE.md +0 -22
  170. data/README.md +0 -58
  171. data/lib/bundler/compact_index_client/cache.rb +0 -107
  172. data/lib/bundler/compact_index_client/cache_file.rb +0 -148
  173. data/lib/bundler/compact_index_client/parser.rb +0 -87
  174. data/lib/bundler/compact_index_client/updater.rb +0 -105
  175. data/lib/bundler/compact_index_client.rb +0 -92
  176. data/lib/bundler/vendor/connection_pool/lib/connection_pool/timed_stack.rb +0 -227
  177. data/lib/bundler/vendor/connection_pool/lib/connection_pool/version.rb +0 -3
  178. data/lib/bundler/vendor/connection_pool/lib/connection_pool/wrapper.rb +0 -56
  179. data/lib/bundler/vendor/connection_pool/lib/connection_pool.rb +0 -230
  180. data/lib/bundler/vendor/net-http-persistent/README.rdoc +0 -82
  181. data/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/connection.rb +0 -41
  182. data/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/pool.rb +0 -65
  183. data/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/timed_stack_multi.rb +0 -80
  184. data/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent.rb +0 -1153
  185. data/lib/bundler/vendor/pub_grub/lib/pub_grub.rb +0 -31
  186. data/lib/bundler/vendored_persistent.rb +0 -11
  187. data/lib/bundler/yaml_serializer.rb +0 -98
  188. /data/lib/{bundler → rubygems}/vendor/pub_grub/LICENSE.txt +0 -0
  189. /data/lib/{bundler → rubygems}/vendor/securerandom/COPYING +0 -0
  190. /data/lib/{bundler → rubygems}/vendor/uri/COPYING +0 -0
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Skip reloading when an identical copy (e.g. the one shipped inside the Bundler
4
+ # gem) was already required from a different path, to avoid redefinition warnings.
5
+ return if defined?(Gem::CompactIndexClient::INFO_REQS)
6
+
7
+ ##
8
+ # The CompactIndexClient fetches and parses the compact index files
9
+ # (names, versions and info/[gem]) served by a gem server, keeping a
10
+ # local cache so subsequent fetches only transfer what changed.
11
+ #
12
+ # This is the single shared client: Bundler ships a copy of it and uses
13
+ # it underneath its own fetcher orchestration, injecting its fetcher and
14
+ # filesystem access hook.
15
+
16
+ class Gem::CompactIndexClient
17
+ SUPPORTED_DIGESTS = { "sha-256" => :SHA256 }.freeze
18
+ DEBUG_MUTEX = Thread::Mutex.new
19
+
20
+ # info returns an Array of INFO Arrays. Each INFO Array has the following indices:
21
+ INFO_NAME = 0
22
+ INFO_VERSION = 1
23
+ INFO_SUFFIX = 2
24
+ INFO_DEPS = 3
25
+ INFO_REQS = 4
26
+
27
+ INFO_PLATFORM = INFO_SUFFIX
28
+ deprecate_constant :INFO_PLATFORM
29
+
30
+ def self.debug
31
+ return unless ENV["DEBUG_COMPACT_INDEX"]
32
+ DEBUG_MUTEX.synchronize { warn("[#{self}] #{yield}") }
33
+ end
34
+
35
+ class Error < StandardError; end
36
+
37
+ # Filesystem reads and writes funnel through this hook so that a host
38
+ # (e.g. Bundler) can translate low-level Errno exceptions into its own
39
+ # friendlier errors. The default just yields the path.
40
+ def self.filesystem_access(path, action = :write, &block)
41
+ if @filesystem_access
42
+ @filesystem_access.call(path, action, &block)
43
+ else
44
+ yield path
45
+ end
46
+ end
47
+
48
+ def self.filesystem_access=(hook)
49
+ @filesystem_access = hook
50
+ end
51
+
52
+ require_relative "compact_index_client/cache"
53
+ require_relative "compact_index_client/cache_file"
54
+ require_relative "compact_index_client/http_fetcher"
55
+ require_relative "compact_index_client/parser"
56
+ require_relative "compact_index_client/updater"
57
+
58
+ # The client is instantiated with:
59
+ # - `directory`: the root directory where the cache files are stored.
60
+ # - `fetcher`: (optional) an object that responds to #call(uri_path, headers)
61
+ # and returns a Gem::Net::HTTP response. When the fetcher is not provided,
62
+ # the client only reads cached files from disk.
63
+ def initialize(directory, fetcher = nil)
64
+ @cache = Cache.new(directory, fetcher)
65
+ @parser = Parser.new(@cache)
66
+ end
67
+
68
+ def names
69
+ Gem::CompactIndexClient.debug { "names" }
70
+ @parser.names
71
+ end
72
+
73
+ def versions
74
+ Gem::CompactIndexClient.debug { "versions" }
75
+ @parser.versions
76
+ end
77
+
78
+ def dependencies(names)
79
+ Gem::CompactIndexClient.debug { "dependencies(#{names})" }
80
+ names.map {|name| info(name) }
81
+ end
82
+
83
+ def info(name)
84
+ Gem::CompactIndexClient.debug { "info(#{name})" }
85
+ @parser.info(name)
86
+ end
87
+
88
+ # Fetches a single gem's info without consulting the versions index,
89
+ # using a conditional request to refresh the cached file. Useful when
90
+ # only a few gems are needed and the versions index download would
91
+ # dominate, as in gem install.
92
+ def fetch_info(name)
93
+ Gem::CompactIndexClient.debug { "fetch_info(#{name})" }
94
+ @parser.parse_info(@cache.fetch_info(name), name)
95
+ end
96
+
97
+ def latest_version(name)
98
+ Gem::CompactIndexClient.debug { "latest_version(#{name})" }
99
+ @parser.info(name).map {|d| Gem::Version.new(d[INFO_VERSION]) }.max
100
+ end
101
+
102
+ def available?
103
+ Gem::CompactIndexClient.debug { "available?" }
104
+ @parser.available?
105
+ end
106
+
107
+ def reset!
108
+ Gem::CompactIndexClient.debug { "reset!" }
109
+ @cache.reset!
110
+ end
111
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Skip reloading when an identical copy (e.g. the one shipped inside the Bundler
4
+ # gem) was already required from a different path, to avoid redefinition warnings.
5
+ return if defined?(Gem::CooldownSettings)
6
+
7
+ ##
8
+ # The rules RubyGems and Bundler share for reading a configured cooldown
9
+ # period. Bundler ships a copy of this file, so it deliberately has no
10
+ # requires: none of the rest of RubyGems is guaranteed to be around it.
11
+
12
+ module Gem::CooldownSettings
13
+ ##
14
+ # +value+ read as a number of days, or nil when it is absent or cannot be
15
+ # read as a non-negative integer. A gemrc entry and a Bundler config entry
16
+ # are both arbitrary YAML, so either can be any type at all.
17
+
18
+ def self.days(value)
19
+ return if value.nil?
20
+
21
+ # Base 10 explicitly: a leading zero is how a user writes a small number
22
+ # of days, not a request for octal.
23
+ days = Integer(value.to_s, 10, exception: false)
24
+
25
+ days if days && !days.negative?
26
+ end
27
+
28
+ ##
29
+ # True when +value+ is configured but cannot be read as a number of days.
30
+
31
+ def self.invalid?(value)
32
+ !value.nil? && days(value).nil?
33
+ end
34
+
35
+ ##
36
+ # The cooldown that applies when RubyGems and Bundler are configured
37
+ # separately: the longest of +values+, so a cooldown configured for only
38
+ # one of the two tools protects both. Returns nil when none of them is
39
+ # usable.
40
+ #
41
+ # A configured 0 takes part like any other value rather than switching the
42
+ # cooldown off, which leaves <tt>--cooldown 0</tt> as the way to bypass a
43
+ # cooldown that the other tool configures.
44
+
45
+ def self.combine(*values)
46
+ values.filter_map {|value| days(value) }.max
47
+ end
48
+
49
+ # +source+ names where the value came from, since RubyGems and Bundler now
50
+ # each read the other's setting and the same complaint from either of them
51
+ # is otherwise impossible to trace back to a file.
52
+
53
+ def self.invalid_message(value, source) # :nodoc:
54
+ "Invalid cooldown value #{value.inspect} in #{source}, so it is ignored. " \
55
+ "Expected a non-negative integer number of days."
56
+ end
57
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ class Gem::CredentialStore; end unless defined?(Gem::CredentialStore)
6
+
7
+ ##
8
+ # Stores credentials in the Secret Service API (GNOME Keyring, KWallet,
9
+ # ...) via the +secret-tool+ command line tool from libsecret.
10
+
11
+ class Gem::CredentialStore::LinuxBackend
12
+ # secret-tool prints an item's attributes to stderr, one per line.
13
+ ACCOUNT_ATTRIBUTE = /^attribute\.account = (.*)$/
14
+ def self.available?
15
+ return @available if defined?(@available)
16
+
17
+ @available = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir|
18
+ File.executable?(File.join(dir, "secret-tool"))
19
+ end
20
+ end
21
+
22
+ ##
23
+ # Clears the memoized #available? result. Intended for tests only.
24
+
25
+ def self.reset!
26
+ remove_instance_variable(:@available) if defined?(@available)
27
+ end
28
+
29
+ def self.get(service, account)
30
+ out, err, status = Open3.capture3(
31
+ "secret-tool", "lookup", "service", service, "account", account
32
+ )
33
+ # secret-tool exits 1 with nothing on stderr when the entry is simply
34
+ # absent. Anything else is a real failure.
35
+ unless status.success?
36
+ return nil if status.exitstatus == 1 && err.to_s.strip.empty?
37
+
38
+ raise "secret-tool exited with #{status.exitstatus}: #{err.strip}"
39
+ end
40
+
41
+ secret = out.chomp
42
+ secret.empty? ? nil : secret
43
+ end
44
+
45
+ def self.set(service, account, secret)
46
+ _out, status = Open3.capture2(
47
+ "secret-tool", "store", "--label=RubyGems", "service", service, "account", account,
48
+ stdin_data: secret
49
+ )
50
+ status.success?
51
+ end
52
+
53
+ # secret-tool writes attributes to stderr and secrets to stdout, so accounts
54
+ # are read from stderr. Discarding stdout also keeps a secret containing a
55
+ # newline from being mistaken for an attribute line.
56
+ def self.list(service)
57
+ _out, err, status = Open3.capture3(
58
+ "secret-tool", "search", "--all", "service", service
59
+ )
60
+ return [] unless status.success?
61
+
62
+ err.scan(ACCOUNT_ATTRIBUTE).flatten.uniq
63
+ end
64
+
65
+ def self.delete(service, account)
66
+ _out, err, status = Open3.capture3(
67
+ "secret-tool", "clear", "service", service, "account", account
68
+ )
69
+ return cleared?(service, account) if status.success?
70
+
71
+ # secret-tool clear exits 1 with no stderr when nothing matched.
72
+ status.exitstatus == 1 && err.to_s.strip.empty?
73
+ end
74
+
75
+ def self.delete_all(service)
76
+ _out, err, status = Open3.capture3(
77
+ "secret-tool", "clear", "service", service
78
+ )
79
+ return cleared?(service) if status.success?
80
+
81
+ status.exitstatus == 1 && err.to_s.strip.empty?
82
+ end
83
+
84
+ # libsecret clears only unlocked items and reports no error for the ones it
85
+ # skipped, so a locked keyring answers a clear with success while keeping
86
+ # every secret. search exits zero either way, so its output is the answer.
87
+ def self.cleared?(service, account = nil)
88
+ _out, err, status = Open3.capture3(
89
+ "secret-tool", "search", "--all", "service", service
90
+ )
91
+ return false unless status.success?
92
+
93
+ remaining = err.scan(ACCOUNT_ATTRIBUTE).flatten
94
+ account ? !remaining.include?(account) : remaining.empty?
95
+ end
96
+ private_class_method :cleared?
97
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ class Gem::CredentialStore; end unless defined?(Gem::CredentialStore)
6
+
7
+ ##
8
+ # Stores credentials in the macOS Keychain via the +security+ command line
9
+ # tool. +security+ has no way to read a password from stdin as raw bytes
10
+ # for +add-generic-password+, so #set uses +security -i+ (batch/interactive
11
+ # mode, one tokenized command per stdin line) to keep the secret off argv
12
+ # and out of +ps+ output.
13
+ #
14
+ # A newline would start a second command in the +security -i+ batch, and
15
+ # +security find-generic-password -w+ prints any non-printable byte back as a
16
+ # hex string rather than the original value, so a non-ASCII secret would
17
+ # round-trip corrupted. Those are the limits Gem::CredentialStore#set enforces
18
+ # for every backend, so the caller falls back to file storage rather than
19
+ # storing something that cannot be read back.
20
+
21
+ class Gem::CredentialStore::MacOSBackend
22
+ NOT_FOUND_STATUS = 44
23
+
24
+ def self.get(service, account)
25
+ out, err, status = Open3.capture3(
26
+ "security", "find-generic-password", "-a", account, "-s", service, "-w"
27
+ )
28
+ # A locked keychain and an absent entry both yield no secret, but only the
29
+ # second one is ordinary.
30
+ unless status.success?
31
+ return nil if status.exitstatus == NOT_FOUND_STATUS
32
+
33
+ raise "security exited with #{status.exitstatus}: #{err.strip}"
34
+ end
35
+
36
+ secret = out.chomp
37
+ secret.empty? ? nil : secret
38
+ end
39
+
40
+ def self.set(service, account, secret)
41
+ command = "add-generic-password -U -a #{quote(account)} -s #{quote(service)} -w #{quote(secret)}\n"
42
+ _out, err, status = Open3.capture3("security", "-i", stdin_data: command)
43
+ return true if status.success?
44
+
45
+ # Raise rather than return false so the reason reaches the user. The
46
+ # wrapper turns it back into false after reporting it.
47
+ raise "security exited with #{status.exitstatus}: #{err.strip}"
48
+ end
49
+
50
+ # security has no "list by service" subcommand, so this reads the dump, which
51
+ # never reports the secrets.
52
+ def self.list(service)
53
+ out, status = Open3.capture2("security", "dump-keychain", err: File::NULL)
54
+ return [] unless status.success?
55
+
56
+ out.split(/^keychain: /).filter_map do |entry|
57
+ next unless entry[/^\s*"svce"<blob>="(.*)"$/, 1] == service
58
+
59
+ entry[/^\s*"acct"<blob>="(.*)"$/, 1]
60
+ end.uniq
61
+ end
62
+
63
+ def self.delete(service, account)
64
+ _out, status = Open3.capture2(
65
+ "security", "delete-generic-password", "-a", account, "-s", service,
66
+ err: File::NULL
67
+ )
68
+ status.success? || status.exitstatus == NOT_FOUND_STATUS
69
+ end
70
+
71
+ # security deletes one entry per call, so keep going until it reports there
72
+ # is nothing left (exit 44). Other services are untouched.
73
+ def self.delete_all(service)
74
+ loop do
75
+ _out, status = Open3.capture2(
76
+ "security", "delete-generic-password", "-s", service,
77
+ err: File::NULL
78
+ )
79
+ return true if status.exitstatus == NOT_FOUND_STATUS
80
+ return false unless status.success?
81
+ end
82
+ end
83
+
84
+ def self.quote(value)
85
+ %("#{value.gsub("\\", "\\\\\\\\").gsub('"', '\\"')}")
86
+ end
87
+ private_class_method :quote
88
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ class Gem::CredentialStore; end unless defined?(Gem::CredentialStore)
6
+
7
+ ##
8
+ # Stores credentials in the Windows Credential Manager via the
9
+ # +Windows.Security.Credentials.PasswordVault+ WinRT API, driven from
10
+ # PowerShell. Account/service/secret values are passed as environment
11
+ # variables rather than interpolated into the script text, so no quoting
12
+ # scheme is needed and values cannot break out of the script.
13
+ #
14
+ # Windows PowerShell is used rather than PowerShell 7 (+pwsh+) because the
15
+ # WinRT projection used here is not reliably available under pwsh. It is
16
+ # spawned as +powershell+ rather than +powershell.exe+, the way this codebase
17
+ # spawns +git+, so PATHEXT resolves it. That also lets the tests put a shim
18
+ # ahead of it on Windows, where a file with a shebang is not executable.
19
+
20
+ class Gem::CredentialStore::WindowsBackend
21
+ LOAD_VAULT_TYPE = <<~POWERSHELL
22
+ $ErrorActionPreference = 'Stop'
23
+ [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
24
+ POWERSHELL
25
+ private_constant :LOAD_VAULT_TYPE
26
+
27
+ def self.get(service, account)
28
+ script = <<~POWERSHELL
29
+ #{LOAD_VAULT_TYPE}
30
+ $vault = New-Object Windows.Security.Credentials.PasswordVault
31
+ $credential = $vault.Retrieve($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT)
32
+ # Emitting into the pipeline would send the string through PowerShell's
33
+ # formatter, which wraps at the host width and would corrupt a secret.
34
+ [Console]::Out.Write($credential.Password)
35
+ POWERSHELL
36
+
37
+ out, err, status = run(script, service, account)
38
+ # An absent credential is ordinary. Any other failure is not, so raise and
39
+ # let the wrapper report why rather than authenticating without one.
40
+ unless status.success?
41
+ return nil if missing_credential?(err)
42
+
43
+ raise "powershell exited with #{status.exitstatus}: #{err.strip}"
44
+ end
45
+
46
+ secret = out.chomp
47
+ secret.empty? ? nil : secret
48
+ end
49
+
50
+ def self.set(service, account, secret)
51
+ script = <<~POWERSHELL
52
+ #{LOAD_VAULT_TYPE}
53
+ $vault = New-Object Windows.Security.Credentials.PasswordVault
54
+ try {
55
+ $existing = $vault.Retrieve($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT)
56
+ $vault.Remove($existing)
57
+ } catch {}
58
+ $credential = New-Object Windows.Security.Credentials.PasswordCredential($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT, $env:RUBYGEMS_CRED_SECRET)
59
+ $vault.Add($credential)
60
+ POWERSHELL
61
+
62
+ _out, err, status = run(script, service, account, secret)
63
+ return true if status.success?
64
+
65
+ # Raise so the reason reaches the user; see MacOSBackend.set.
66
+ raise "powershell exited with #{status.exitstatus}: #{err.strip}"
67
+ end
68
+
69
+ def self.delete(service, account)
70
+ script = <<~POWERSHELL
71
+ #{LOAD_VAULT_TYPE}
72
+ $vault = New-Object Windows.Security.Credentials.PasswordVault
73
+ $credential = $vault.Retrieve($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT)
74
+ $vault.Remove($credential)
75
+ POWERSHELL
76
+
77
+ _out, err, status = run(script, service, account)
78
+ status.success? || missing_credential?(err)
79
+ end
80
+
81
+ # FindAllByResource raises when the resource has no entries, which is an
82
+ # empty list rather than an error.
83
+ def self.list(service)
84
+ script = <<~POWERSHELL
85
+ #{LOAD_VAULT_TYPE}
86
+ $vault = New-Object Windows.Security.Credentials.PasswordVault
87
+ try {
88
+ $vault.FindAllByResource($env:RUBYGEMS_CRED_SERVICE) | ForEach-Object {
89
+ [Console]::Out.WriteLine($_.UserName)
90
+ }
91
+ } catch {
92
+ if (-not ($_.Exception.Message -match 'not found|0x80070490')) { throw }
93
+ }
94
+ POWERSHELL
95
+
96
+ out, _err, status = run(script, service, nil)
97
+ return [] unless status.success?
98
+
99
+ out.split("\n").map(&:chomp).reject(&:empty?).uniq
100
+ end
101
+
102
+ def self.delete_all(service)
103
+ script = <<~POWERSHELL
104
+ #{LOAD_VAULT_TYPE}
105
+ $vault = New-Object Windows.Security.Credentials.PasswordVault
106
+ try {
107
+ $vault.FindAllByResource($env:RUBYGEMS_CRED_SERVICE) | ForEach-Object { $vault.Remove($_) }
108
+ } catch {
109
+ if (-not ($_.Exception.Message -match 'not found|0x80070490')) { throw }
110
+ }
111
+ POWERSHELL
112
+
113
+ _out, err, status = run(script, service, nil)
114
+ status.success? || missing_credential?(err)
115
+ end
116
+
117
+ def self.run(script, service, account, secret = nil)
118
+ env = { "RUBYGEMS_CRED_SERVICE" => service, "RUBYGEMS_CRED_ACCOUNT" => account }
119
+ env["RUBYGEMS_CRED_SECRET"] = secret if secret
120
+
121
+ Open3.capture3(env, "powershell", "-NoProfile", "-NonInteractive", "-Command", "-", stdin_data: script)
122
+ end
123
+ private_class_method :run
124
+
125
+ def self.missing_credential?(message)
126
+ text = message.to_s.downcase
127
+ text.include?("element not found") || text.include?("0x80070490") || text.include?("could not be found")
128
+ end
129
+ private_class_method :missing_credential?
130
+ end