portage-cli 0.7.0 → 0.7.4
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +142 -0
- data/README.md +266 -17
- data/lib/portage/cli/buy.rb +187 -41
- data/lib/portage/cli/doctor.rb +130 -2
- data/lib/portage/cli/find.rb +2 -1
- data/lib/portage/cli/handoff_reconciler/wire_adapters.rb +50 -0
- data/lib/portage/cli/handoff_reconciler.rb +249 -0
- data/lib/portage/cli/handoff_spend_mode.rb +28 -0
- data/lib/portage/cli/handoff_wait_timeout.rb +49 -0
- data/lib/portage/cli/handoff_waiter.rb +103 -0
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/install_doctor.rb +239 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +6 -3
- data/lib/portage/cli/payment_methods.rb +6 -4
- data/lib/portage/cli/permissive_authenticator.rb +16 -0
- data/lib/portage/cli/proxy_doctor.rb +149 -0
- data/lib/portage/cli/proxy_settings/password_ref.rb +48 -0
- data/lib/portage/cli/proxy_settings.rb +350 -0
- data/lib/portage/cli/reconcile_notifier.rb +65 -0
- data/lib/portage/cli/reconcile_notify.rb +38 -0
- data/lib/portage/cli/search_backends.rb +6 -4
- data/lib/portage/cli/shipping_profile.rb +7 -2
- data/lib/portage/cli/user_agent.rb +39 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +25 -0
- data/lib/portage/cli/webmcp_checkout_mode.rb +28 -0
- data/lib/portage/cli.rb +249 -15
- metadata +37 -6
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
require "rbconfig"
|
|
2
|
+
require_relative "version"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# Phase 4 of docs/plans/homebrew-distribution.md's `doctor` checks: how
|
|
7
|
+
# this copy of portage-cli was installed (Homebrew formula or plain
|
|
8
|
+
# `gem install`), which Ruby it runs on, which first-party adapter gems
|
|
9
|
+
# it can load, and whether the `portage` a shell would actually run is
|
|
10
|
+
# this one.
|
|
11
|
+
#
|
|
12
|
+
# That last check is the one that matters. The formula installs into its
|
|
13
|
+
# own Cellar keg, and a `gem install portage-cli` copy in a mise/rbenv/
|
|
14
|
+
# asdf/rvm Ruby's bin usually comes earlier on PATH, so `portage` in a
|
|
15
|
+
# shell silently keeps running the old gem after `brew install` or
|
|
16
|
+
# `brew upgrade` (Homebrew's own caveat hit exactly this on the
|
|
17
|
+
# maintainer's machine). Homebrew's `bin/portage` is a symlink into the
|
|
18
|
+
# Cellar, whose env_script wrapper execs the libexec binary, so every
|
|
19
|
+
# comparison here is between canonical Cellar locations, never raw PATH
|
|
20
|
+
# entries.
|
|
21
|
+
#
|
|
22
|
+
# Everything is read from the filesystem and the process: no `brew`
|
|
23
|
+
# shell-out, no network, so doctor stays fast and works offline (the
|
|
24
|
+
# formula's `test do` block runs it in Homebrew's sandbox).
|
|
25
|
+
class InstallDoctor
|
|
26
|
+
DEFAULT_HOMEBREW_PREFIXES = %w[/opt/homebrew /usr/local /home/linuxbrew/.linuxbrew].freeze
|
|
27
|
+
ADAPTER_GEMS = %w[shopify wix woocommerce bigcommerce magento etsy instagram webmcp decision]
|
|
28
|
+
.map { |name| "portage-ucp-#{name}" }.freeze
|
|
29
|
+
EXECUTABLE = "portage".freeze
|
|
30
|
+
|
|
31
|
+
def self.homebrew_prefixes
|
|
32
|
+
[ENV.fetch("HOMEBREW_PREFIX", nil), *DEFAULT_HOMEBREW_PREFIXES].reject { |p| p.to_s.empty? }.uniq
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param path [String] PATH to search for `portage` executables.
|
|
36
|
+
# @param homebrew_prefixes [Array<String>] candidate HOMEBREW_PREFIXes.
|
|
37
|
+
# @param gem_dir [String] this gem's own directory (its `lib/`'s parent).
|
|
38
|
+
# @param ruby [String] the running Ruby's executable path.
|
|
39
|
+
# @param adapter_probe [#call] gem name -> { installed:, loadable:, version:, error: }.
|
|
40
|
+
def initialize(path: ENV.fetch("PATH", ""), homebrew_prefixes: self.class.homebrew_prefixes,
|
|
41
|
+
gem_dir: File.expand_path("../../..", __dir__), ruby: RbConfig.ruby,
|
|
42
|
+
adapter_probe: method(:probe_adapter))
|
|
43
|
+
@path = path
|
|
44
|
+
@homebrew_prefixes = homebrew_prefixes.map { |prefix| canonical(prefix) }
|
|
45
|
+
@gem_dir = canonical(gem_dir)
|
|
46
|
+
@ruby = ruby
|
|
47
|
+
@adapter_probe = adapter_probe
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def findings
|
|
51
|
+
[install_finding, runtime_finding, adapters_finding, path_finding].compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String, nil] the HOMEBREW_PREFIX this copy runs from, nil for a gem install.
|
|
55
|
+
def homebrew_prefix
|
|
56
|
+
return @homebrew_prefix if defined?(@homebrew_prefix)
|
|
57
|
+
|
|
58
|
+
@homebrew_prefix = @homebrew_prefixes.find do |prefix|
|
|
59
|
+
[@gem_dir, canonical(@ruby)].any? { |dir| under?(dir, keg_root(prefix)) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def homebrew? = !homebrew_prefix.nil?
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# --- install method -------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def install_finding
|
|
70
|
+
method = homebrew? ? "homebrew" : "gem"
|
|
71
|
+
path = homebrew? ? installed_keg : @gem_dir
|
|
72
|
+
details = { method: method, path: path }
|
|
73
|
+
details[:prefix] = homebrew_prefix if homebrew?
|
|
74
|
+
info("install", "#{method} (#{path})", details)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def keg_root(prefix) = File.join(prefix, "Cellar", "portage")
|
|
78
|
+
|
|
79
|
+
# `<prefix>/Cellar/portage/<version>`, the keg this copy lives in.
|
|
80
|
+
def installed_keg
|
|
81
|
+
root = keg_root(homebrew_prefix)
|
|
82
|
+
version = @gem_dir.delete_prefix("#{root}/").split("/").first
|
|
83
|
+
File.join(root, version)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# --- runtime --------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def runtime_finding
|
|
89
|
+
info("runtime", "Ruby #{RUBY_VERSION} (#{@ruby}), portage-cli #{VERSION}",
|
|
90
|
+
{ ruby_version: RUBY_VERSION, ruby_path: @ruby, portage_cli_version: VERSION })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- adapters -------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
# A gem install without adapters is normal: `gem install portage-cli`
|
|
96
|
+
# pulls in none of them, and you add the ones you use. The formula
|
|
97
|
+
# bundles every one, so a Homebrew install missing one is broken.
|
|
98
|
+
def adapters_finding
|
|
99
|
+
report = AdapterReport.new(ADAPTER_GEMS.map { |name| { name: name }.merge(safe_probe(name)) })
|
|
100
|
+
details = { adapters: report.adapters }
|
|
101
|
+
return warning("adapters", report.homebrew_missing_message, details) if homebrew? && report.missing?
|
|
102
|
+
|
|
103
|
+
info("adapters", report.summary, details)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def safe_probe(name)
|
|
107
|
+
@adapter_probe.call(name)
|
|
108
|
+
rescue StandardError, ScriptError => e
|
|
109
|
+
{ installed: true, loadable: false, version: nil, error: "#{e.class}: #{e.message}" }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def probe_adapter(name)
|
|
113
|
+
specs = Gem::Specification.find_all_by_name(name)
|
|
114
|
+
return { installed: false, loadable: false, version: nil } if specs.empty?
|
|
115
|
+
|
|
116
|
+
require "portage/ucp/#{name.delete_prefix('portage-ucp-')}"
|
|
117
|
+
spec = Gem.loaded_specs[name] || specs.max_by(&:version)
|
|
118
|
+
{ installed: true, loadable: true, version: spec.version.to_s }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- PATH shadowing -------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def path_finding
|
|
124
|
+
candidates = path_candidates
|
|
125
|
+
return if candidates.empty?
|
|
126
|
+
|
|
127
|
+
details = { first: candidates.first[:path], candidates: candidates }
|
|
128
|
+
message = shadow_message(candidates.first)
|
|
129
|
+
return warning("path", message, details) if message
|
|
130
|
+
|
|
131
|
+
# Only a Homebrew keg can be matched to this copy for certain; a gem
|
|
132
|
+
# install's binstub can sit behind a mise/rbenv shim.
|
|
133
|
+
suffix = homebrew? ? " (this install)" : ""
|
|
134
|
+
info("path", "`portage` on PATH is #{candidates.first[:path]}#{suffix}", details)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Every `portage` on PATH, first match first, one entry per real file:
|
|
138
|
+
# the same Homebrew bin can sit on PATH twice, and `bin/portage` and
|
|
139
|
+
# `opt/portage/bin/portage` both resolve to one keg.
|
|
140
|
+
def path_candidates
|
|
141
|
+
candidates = @path.split(File::PATH_SEPARATOR).reject(&:empty?).filter_map { |dir| candidate(dir) }
|
|
142
|
+
candidates.uniq { |c| c[:realpath] }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def candidate(dir)
|
|
146
|
+
file = File.join(dir, EXECUTABLE)
|
|
147
|
+
return unless File.file?(file) && File.executable?(file)
|
|
148
|
+
|
|
149
|
+
real = canonical(file)
|
|
150
|
+
{ path: file, realpath: real, kind: homebrew_keg?(real) ? "homebrew" : "other" }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def shadow_message(first)
|
|
154
|
+
if homebrew?
|
|
155
|
+
return if under?(first[:realpath], keg_root(homebrew_prefix))
|
|
156
|
+
|
|
157
|
+
homebrew_shadowed_message(first)
|
|
158
|
+
elsif first[:kind] == "homebrew"
|
|
159
|
+
gem_shadowed_message(first)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def homebrew_shadowed_message(first)
|
|
164
|
+
brew_bin = File.join(homebrew_prefix, "bin")
|
|
165
|
+
"`portage` on PATH runs #{first[:path]}, not this Homebrew install (#{brew_bin}/portage), " \
|
|
166
|
+
"so `brew upgrade portage` won't change what your shell runs. Check with `which -a portage`. " \
|
|
167
|
+
"Fix: if that's a `gem install` copy, run `gem uninstall portage-cli` with that Ruby active; " \
|
|
168
|
+
"or put #{brew_bin} ahead of #{File.dirname(first[:path])} in PATH."
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def gem_shadowed_message(first)
|
|
172
|
+
"`portage` on PATH runs the Homebrew install (#{first[:path]}), not this gem install " \
|
|
173
|
+
"(#{@gem_dir}). Check with `which -a portage`. Fix: keep one — `brew uninstall portage` " \
|
|
174
|
+
"to use the gem, or `gem uninstall portage-cli` to use Homebrew; or reorder PATH."
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def homebrew_keg?(realpath)
|
|
178
|
+
@homebrew_prefixes.any? { |prefix| under?(realpath, keg_root(prefix)) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# --- helpers --------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def under?(path, root) = path.start_with?("#{root}/")
|
|
184
|
+
|
|
185
|
+
def canonical(path)
|
|
186
|
+
File.realpath(path)
|
|
187
|
+
rescue SystemCallError
|
|
188
|
+
File.expand_path(path)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def info(check, message, details) = finding(check, message, details, "info")
|
|
192
|
+
def warning(check, message, details) = finding(check, message, details, "warning")
|
|
193
|
+
|
|
194
|
+
def finding(check, message, details, level)
|
|
195
|
+
Doctor::Finding.new(check: check, message: message, level: level, details: details)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The adapters line of InstallDoctor's report, one probe result per gem:
|
|
200
|
+
# `{ name:, installed:, loadable:, version:, error: }`.
|
|
201
|
+
class InstallDoctor
|
|
202
|
+
class AdapterReport
|
|
203
|
+
attr_reader :adapters
|
|
204
|
+
|
|
205
|
+
def initialize(adapters)
|
|
206
|
+
@adapters = adapters
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def missing = @adapters.reject { |a| a[:loadable] }
|
|
210
|
+
def missing? = missing.any?
|
|
211
|
+
|
|
212
|
+
def summary
|
|
213
|
+
loaded, broken, absent = partitioned
|
|
214
|
+
parts = [loaded.empty? ? "none loadable" : loaded.map { |a| "#{short(a)} #{a[:version]}" }.join(", ")]
|
|
215
|
+
parts << "failed to load: #{names(broken)}" unless broken.empty?
|
|
216
|
+
parts << "not installed: #{names(absent)}" unless absent.empty?
|
|
217
|
+
parts.join("; ")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def homebrew_missing_message
|
|
221
|
+
described = missing.map { |a| a[:error] ? "#{short(a)} (#{a[:error]})" : short(a) }
|
|
222
|
+
"This Homebrew install bundles every adapter, but #{described.join(', ')} can't be loaded — " \
|
|
223
|
+
"reinstall with `brew reinstall portage`."
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def partitioned
|
|
229
|
+
loaded, rest = @adapters.partition { |a| a[:loadable] }
|
|
230
|
+
broken, absent = rest.partition { |a| a[:installed] }
|
|
231
|
+
[loaded, broken, absent]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def names(adapters) = adapters.map { |a| short(a) }.join(", ")
|
|
235
|
+
def short(adapter) = adapter[:name].delete_prefix("portage-ucp-")
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# docs/plans/handoff-reconcile.md Phase 3's "macos" notify channel — a
|
|
4
|
+
# native notification banner for a settled handoff. Opt-in (see
|
|
5
|
+
# ReconcileNotify), and a no-op off Darwin.
|
|
6
|
+
#
|
|
7
|
+
# `system("osascript", "-e", script)` in array form, never an
|
|
8
|
+
# interpolated shell string — same posture as CheckoutHandoff's
|
|
9
|
+
# `open`/`xdg-open`. The injection surface that remains is the
|
|
10
|
+
# AppleScript source itself: `title`/`message` come from a merchant
|
|
11
|
+
# name and an amount, both untrusted, so they're never spliced into the
|
|
12
|
+
# script raw. #applescript_string escapes backslashes and double quotes
|
|
13
|
+
# before interpolating, so a merchant name like `foo" & do shell script
|
|
14
|
+
# "rm -rf ~" & "` can't break out of its quoted string and run as
|
|
15
|
+
# AppleScript.
|
|
16
|
+
class MacosNotifier
|
|
17
|
+
def call(title:, message:)
|
|
18
|
+
return false unless RbConfig::CONFIG["host_os"] =~ /darwin/i
|
|
19
|
+
|
|
20
|
+
script = "display notification #{applescript_string(message)} with title #{applescript_string(title)}"
|
|
21
|
+
!!system("osascript", "-e", script)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def applescript_string(text)
|
|
29
|
+
# Block form, not a replacement string: gsub treats "\\" in a
|
|
30
|
+
# replacement *string* as a backreference escape, which would
|
|
31
|
+
# collapse a literal backslash right back down to one.
|
|
32
|
+
escaped = text.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\"' }
|
|
33
|
+
"\"#{escaped}\""
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/portage/cli/notifier.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
|
+
require "portage/ucp"
|
|
5
|
+
require "portage/ucp/support/connection"
|
|
4
6
|
require_relative "config"
|
|
5
7
|
require_relative "setting"
|
|
8
|
+
require_relative "user_agent"
|
|
6
9
|
|
|
7
10
|
module Portage
|
|
8
11
|
module Cli
|
|
@@ -63,9 +66,9 @@ module Portage
|
|
|
63
66
|
private
|
|
64
67
|
|
|
65
68
|
def post(uri, body)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
http.post(uri.request_uri, body, "Content-Type" => "application/json")
|
|
69
|
+
Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: TIMEOUT,
|
|
70
|
+
read_timeout: TIMEOUT) do |http|
|
|
71
|
+
http.post(uri.request_uri, body, UserAgent.headers.merge("Content-Type" => "application/json"))
|
|
69
72
|
end
|
|
70
73
|
end
|
|
71
74
|
end
|
|
@@ -5,6 +5,7 @@ require "uri"
|
|
|
5
5
|
require "net/http"
|
|
6
6
|
require "portage/ucp"
|
|
7
7
|
require "portage/ucp/client"
|
|
8
|
+
require "portage/ucp/support/connection"
|
|
8
9
|
|
|
9
10
|
# Buy::PermissiveAuthenticator (adapter-loopback auth) is used by
|
|
10
11
|
# #adapter_session below — not require_relative'd here to avoid a load
|
|
@@ -14,6 +15,7 @@ require "portage/ucp/client"
|
|
|
14
15
|
require_relative "payment_methods/keychain_backend"
|
|
15
16
|
require_relative "payment_methods/secret_service_backend"
|
|
16
17
|
require_relative "payment_methods/env_backend"
|
|
18
|
+
require_relative "user_agent"
|
|
17
19
|
|
|
18
20
|
module Portage
|
|
19
21
|
module Cli
|
|
@@ -199,7 +201,7 @@ module Portage
|
|
|
199
201
|
# entangled with cart/checkout-specific branching this only needs the
|
|
200
202
|
# session object from.
|
|
201
203
|
def discover_session(url)
|
|
202
|
-
native = Portage::Ucp::Client.discover(url)
|
|
204
|
+
native = Portage::Ucp::Client.discover(url, headers: UserAgent.headers)
|
|
203
205
|
native if native
|
|
204
206
|
rescue Portage::Ucp::Client::DiscoveryError
|
|
205
207
|
adapter_session(url)
|
|
@@ -221,9 +223,9 @@ module Portage
|
|
|
221
223
|
end
|
|
222
224
|
|
|
223
225
|
def fetch_homepage(uri)
|
|
224
|
-
response =
|
|
225
|
-
|
|
226
|
-
http.get(uri.request_uri,
|
|
226
|
+
response = Portage::Ucp::Support::Connection.start(uri, route: :payment, open_timeout: 5,
|
|
227
|
+
read_timeout: 5) do |http|
|
|
228
|
+
http.get(uri.request_uri, UserAgent.headers)
|
|
227
229
|
end
|
|
228
230
|
response.is_a?(Net::HTTPSuccess) ? [response.body, response.to_hash] : [nil, {}]
|
|
229
231
|
rescue StandardError
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# Loopback buy against your own store needs *some* authenticator (§9
|
|
6
|
+
# rejects anonymous mutation by default) — since this process already
|
|
7
|
+
# has this platform's own credentials (that's the gate to even reach the
|
|
8
|
+
# adapter-fallback path, in both Buy and HandoffReconciler), authenticating
|
|
9
|
+
# this local CLI session is reasonable. Shared by both rather than
|
|
10
|
+
# defined once as a nested class of Buy, since HandoffReconciler's own
|
|
11
|
+
# reconnect needs it too and shouldn't reach into Buy's internals for it.
|
|
12
|
+
class PermissiveAuthenticator < Portage::Ucp::Authenticator
|
|
13
|
+
def call(_server_context) = :local_cli
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "proxy_settings"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# Phase 2 of docs/plans/proxy-support.md's `doctor` checks for whatever
|
|
7
|
+
# `ProxySettings` resolved: each configured proxy's own reachability (a
|
|
8
|
+
# real CONNECT/GET through it, via Support::Connection — same code path
|
|
9
|
+
# a real request would use, not a re-implementation), whether a
|
|
10
|
+
# configured gateway answers at all, and the two warnings the plan calls
|
|
11
|
+
# out by name (plaintext credentials sitting in config.json, an
|
|
12
|
+
# intercepting proxy on the payment route). Every message redacts
|
|
13
|
+
# credentials the same way Support::Connection.redact does — never the
|
|
14
|
+
# real userinfo, in any finding this produces.
|
|
15
|
+
#
|
|
16
|
+
# Stays entirely separate from Doctor's own Phase 0 `proxy_finding`
|
|
17
|
+
# (the raw $http_proxy/$https_proxy env report) rather than replacing
|
|
18
|
+
# it — that one is still accurate and still worth showing for anyone who
|
|
19
|
+
# hasn't touched Phase 2 flags/config.json at all.
|
|
20
|
+
class ProxyDoctor
|
|
21
|
+
PROBE_URI = URI("https://example.com/").freeze
|
|
22
|
+
ROUTE_NAMES = %w[store search notify payment platform probe].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(proxy_settings:, config: Config.load, timeout: 5, probe_uri: PROBE_URI)
|
|
25
|
+
@proxy_settings = proxy_settings
|
|
26
|
+
@config = config
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
@probe_uri = probe_uri
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def findings
|
|
32
|
+
[*route_summary_findings, *reachability_findings, *plaintext_credential_findings,
|
|
33
|
+
*payment_intercept_findings].compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def routes = @proxy_settings.routes
|
|
39
|
+
|
|
40
|
+
# --- effective routing, credentials redacted -----------------------
|
|
41
|
+
|
|
42
|
+
def route_summary_findings
|
|
43
|
+
return [] unless anything_configured?
|
|
44
|
+
|
|
45
|
+
lines = ROUTE_NAMES.map { |name| "#{name}: #{effective_description(name)}" }
|
|
46
|
+
[Doctor::Finding.new(check: "proxy_routes", message: lines.join("; "))]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def direct_profile = Portage::Ucp::Support::ProxyConfig::Profile::DIRECT
|
|
50
|
+
|
|
51
|
+
def anything_configured?
|
|
52
|
+
routes.any? { |name, spec| name != "payment" || spec != direct_profile }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def effective_description(name)
|
|
56
|
+
return describe(routes[name]) if routes.key?(name)
|
|
57
|
+
return describe(routes["default"]) if routes.key?("default")
|
|
58
|
+
|
|
59
|
+
"unconfigured (falls back to HTTP_PROXY/HTTPS_PROXY env, or direct)"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def describe(spec)
|
|
63
|
+
spec.is_a?(Array) ? spec.map { |hop| describe_hop(hop) }.join(" -> ") : describe_hop(spec)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def describe_hop(hop)
|
|
67
|
+
return "direct" if hop.direct?
|
|
68
|
+
|
|
69
|
+
"#{hop.mode} #{redact(hop.url)}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def redact(url)
|
|
73
|
+
return "(none)" unless url
|
|
74
|
+
|
|
75
|
+
uri = URI(url)
|
|
76
|
+
userinfo = uri.userinfo ? "***@" : ""
|
|
77
|
+
"#{uri.scheme}://#{userinfo}#{uri.host}:#{uri.port}"
|
|
78
|
+
rescue URI::InvalidURIError
|
|
79
|
+
"(unparseable)"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# --- reachability ---------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def reachability_findings
|
|
85
|
+
collect_profiles.filter_map { |profile| reachability_finding(profile) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def collect_profiles
|
|
89
|
+
hops = routes.values.flat_map { |spec| spec.is_a?(Array) ? spec : [spec] }
|
|
90
|
+
hops.reject(&:direct?).uniq(&:url)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def reachability_finding(profile)
|
|
94
|
+
probe(profile)
|
|
95
|
+
nil
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
Doctor::Finding.new(check: "proxy_reachability",
|
|
98
|
+
message: "#{profile.mode} proxy #{redact(profile.url)} is not reachable: " \
|
|
99
|
+
"#{e.class}: #{e.message}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def probe(profile)
|
|
103
|
+
synthetic = Portage::Ucp::Support::ProxyConfig.new(routes: { "probe" => profile })
|
|
104
|
+
Portage::Ucp::Support::Connection.start(@probe_uri, route: "probe", proxy: synthetic,
|
|
105
|
+
open_timeout: @timeout, read_timeout: @timeout) do |http|
|
|
106
|
+
http.get(@probe_uri.request_uri, {})
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- warnings ---------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def plaintext_credential_findings
|
|
113
|
+
offenders = credential_bearing_profiles.select { |profile| plaintext_password?(profile) }
|
|
114
|
+
return [] if offenders.empty?
|
|
115
|
+
|
|
116
|
+
[Doctor::Finding.new(check: "proxy_credentials",
|
|
117
|
+
message: "#{offenders.length} proxy profile(s) in config.json carry a plaintext " \
|
|
118
|
+
"password in their url — use password_ref (Keychain/Secret Service) or " \
|
|
119
|
+
"${ENV} instead.")]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def credential_bearing_profiles
|
|
123
|
+
raw = @config.get("proxy") || {}
|
|
124
|
+
[raw["default"], *raw.fetch("chains", {}).values.flatten, *raw.fetch("routes", {}).values].grep(Hash)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def plaintext_password?(profile)
|
|
128
|
+
uri = URI(profile["url"].to_s)
|
|
129
|
+
!uri.password.to_s.empty?
|
|
130
|
+
rescue URI::InvalidURIError
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def payment_intercept_findings
|
|
135
|
+
spec = routes["payment"]
|
|
136
|
+
return [] unless intercepting?(spec)
|
|
137
|
+
|
|
138
|
+
[Doctor::Finding.new(check: "proxy_payment_intercept",
|
|
139
|
+
message: "the payment route is routed through a proxy that can see plaintext payment " \
|
|
140
|
+
"data (ca_file set, or gateway mode): #{describe(spec)}.")]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def intercepting?(spec)
|
|
144
|
+
hops = spec.is_a?(Array) ? spec : [spec]
|
|
145
|
+
hops.any? { |hop| hop.gateway? || hop.ca_file }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require_relative "../payment_methods"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
class ProxySettings
|
|
7
|
+
# Resolves `proxy.password_ref` (and any per-hop `password_ref`) the
|
|
8
|
+
# same three-tier way PaymentMethods.detect_backend picks a payment
|
|
9
|
+
# secret store: macOS Keychain, then Linux Secret Service, then
|
|
10
|
+
# nothing in headless mode. Deliberately its own service name rather
|
|
11
|
+
# than PaymentMethods::KeychainBackend/SecretServiceBackend directly —
|
|
12
|
+
# those are scoped to "portage-cli-payment", and their headless tier
|
|
13
|
+
# (EnvBackend#read(_id)) answers *every* id with PORTAGE_PAYMENT_TOKEN
|
|
14
|
+
# regardless of what was asked for, which would make a misconfigured
|
|
15
|
+
# proxy.password_ref silently leak the payment token as a proxy
|
|
16
|
+
# password. A ref that can't be resolved here just comes back nil —
|
|
17
|
+
# ProxySettings turns that into a clear config error rather than
|
|
18
|
+
# sending a proxy request with a literal "password_ref" placeholder.
|
|
19
|
+
module PasswordRef
|
|
20
|
+
SERVICE = "portage-cli-proxy".freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def resolve(ref)
|
|
25
|
+
return nil if ref.to_s.strip.empty?
|
|
26
|
+
return keychain(ref) if macos? && Portage::Cli::PaymentMethods.executable?("security")
|
|
27
|
+
return secret_service(ref) if linux_session? && Portage::Cli::PaymentMethods.executable?("secret-tool")
|
|
28
|
+
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def macos? = RUBY_PLATFORM.include?("darwin")
|
|
33
|
+
|
|
34
|
+
def linux_session? = ENV["DBUS_SESSION_BUS_ADDRESS"].to_s != ""
|
|
35
|
+
|
|
36
|
+
def keychain(ref)
|
|
37
|
+
out, status = Open3.capture2("security", "find-generic-password", "-a", ref, "-s", SERVICE, "-w")
|
|
38
|
+
status.success? ? out.chomp : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def secret_service(ref)
|
|
42
|
+
out, status = Open3.capture2("secret-tool", "lookup", "service", SERVICE, "account", ref)
|
|
43
|
+
status.success? ? out.chomp : nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|