Skip to content

Commit fd9b971

Browse files
authored
Merge pull request #22886 from Homebrew/fix-deferred-env-reexec
Expand deferred env in curl URLs
2 parents 49cadd5 + cf46c16 commit fd9b971

10 files changed

Lines changed: 194 additions & 15 deletions

File tree

Library/Homebrew/context.rb

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@ module Context
99

1010
# Struct describing the current execution context.
1111
class ContextStruct
12-
sig { params(debug: T.nilable(T::Boolean), quiet: T.nilable(T::Boolean), verbose: T.nilable(T::Boolean)).void }
13-
def initialize(debug: nil, quiet: nil, verbose: nil)
12+
sig {
13+
params(
14+
debug: T.nilable(T::Boolean),
15+
quiet: T.nilable(T::Boolean),
16+
verbose: T.nilable(T::Boolean),
17+
deferred_environment_expansion: T.nilable(T::Boolean),
18+
).void
19+
}
20+
def initialize(debug: nil, quiet: nil, verbose: nil, deferred_environment_expansion: nil)
1421
@debug = debug
1522
@quiet = quiet
1623
@verbose = verbose
24+
@deferred_environment_expansion = deferred_environment_expansion
1725
end
1826

1927
sig { returns(T::Boolean) }
@@ -30,6 +38,11 @@ def quiet?
3038
def verbose?
3139
@verbose == true
3240
end
41+
42+
sig { returns(T::Boolean) }
43+
def deferred_environment_expansion?
44+
@deferred_environment_expansion == true
45+
end
3346
end
3447

3548
@current = T.let(nil, T.nilable(ContextStruct))
@@ -69,13 +82,24 @@ def verbose?
6982
Context.current.verbose?
7083
end
7184

85+
sig { returns(T::Boolean) }
86+
def deferred_environment_expansion?
87+
Context.current.deferred_environment_expansion?
88+
end
89+
7290
sig {
73-
params(debug: T.nilable(T::Boolean), quiet: T.nilable(T::Boolean), verbose: T.nilable(T::Boolean),
74-
_block: T.proc.void).returns(T.untyped)
91+
params(
92+
debug: T.nilable(T::Boolean),
93+
quiet: T.nilable(T::Boolean),
94+
verbose: T.nilable(T::Boolean),
95+
deferred_environment_expansion: T.nilable(T::Boolean),
96+
_block: T.proc.void,
97+
).returns(T.untyped)
7598
}
76-
def with_context(debug: debug?, quiet: quiet?, verbose: verbose?, &_block)
99+
def with_context(debug: debug?, quiet: quiet?, verbose: verbose?,
100+
deferred_environment_expansion: deferred_environment_expansion?, &_block)
77101
old_context = Context.current
78-
Thread.current[:context] = ContextStruct.new(debug:, quiet:, verbose:)
102+
Thread.current[:context] = ContextStruct.new(debug:, quiet:, verbose:, deferred_environment_expansion:)
79103

80104
begin
81105
yield

Library/Homebrew/download_strategy.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,14 @@ class Mechanize; end
4040
require "download_strategy/bazaar_download_strategy"
4141
require "download_strategy/fossil_download_strategy"
4242
require "download_strategy/download_strategy_detector"
43+
44+
AbstractDownloadStrategy::HOMEBREW_CONTROLLED_STRATEGIES = T.let([
45+
CurlApacheMirrorDownloadStrategy,
46+
CurlDownloadStrategy,
47+
CurlGitHubPackagesDownloadStrategy,
48+
CurlPostDownloadStrategy,
49+
HomebrewCurlDownloadStrategy,
50+
NoUnzipCurlDownloadStrategy,
51+
PyPIDownloadStrategy,
52+
].freeze, T::Array[T::Class[AbstractDownloadStrategy]])
53+
AbstractDownloadStrategy.private_constant :HOMEBREW_CONTROLLED_STRATEGIES

Library/Homebrew/download_strategy/abstract_download_strategy.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ def quiet?
7777
Context.current.quiet? || @quiet || false
7878
end
7979

80+
sig { params(downloader: AbstractDownloadStrategy).returns(T::Boolean) }
81+
def self.expand_deferred_environment_for?(downloader)
82+
HOMEBREW_CONTROLLED_STRATEGIES.include?(downloader.class)
83+
end
84+
8085
# Unpack {#cached_location} into the current working directory.
8186
#
8287
# Additionally, if a block is given, the working directory was previously empty

Library/Homebrew/download_strategy/curl_download_strategy.rb

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class CurlDownloadStrategy < AbstractFileDownloadStrategy
1616
sig { params(url: String, name: String, version: T.nilable(T.any(String, Version)), meta: T.untyped).void }
1717
def initialize(url, name, version, **meta)
1818
@try_partial = T.let(true, T::Boolean)
19+
@expand_deferred_environment = T.let(false, T::Boolean)
1920
@mirrors = T.let(meta.fetch(:mirrors, []), T::Array[String])
2021
@file_size = T.let(nil, T.nilable(Integer))
2122
@last_modified = T.let(nil, T.nilable(Time))
@@ -261,6 +262,20 @@ def _curl_download(resolved_url, to, timeout)
261262
curl_download resolved_url, to:, try_partial: @try_partial, timeout:
262263
end
263264

265+
sig { void }
266+
def allow_deferred_environment_expansion!
267+
@expand_deferred_environment = true
268+
end
269+
270+
sig { params(args: T::Array[String]).returns(T::Array[String]) }
271+
def expand_deferred_environment_args(args)
272+
return args unless @expand_deferred_environment
273+
274+
with_context(deferred_environment_expansion: true) do
275+
args.map { |arg| ENV.expand_deferred_environment(arg) }
276+
end
277+
end
278+
264279
# Curl options to be always passed to curl,
265280
# with raw head calls (`curl --head`) or with actual `fetch`.
266281
sig { returns(T::Array[String]) }
@@ -273,7 +288,7 @@ def _curl_args
273288

274289
args += ["--user", meta.fetch(:user)] if meta.key?(:user)
275290

276-
args += meta.fetch(:headers, []).flat_map { |h| ["--header", ENV.expand_deferred_environment(h).strip] }
291+
args += expand_deferred_environment_args(meta.fetch(:headers, [])).flat_map { |h| ["--header", h.strip] }
277292

278293
args
279294
end
@@ -285,7 +300,7 @@ def _curl_opts
285300

286301
sig { override.params(args: String, options: T.untyped).returns(SystemCommand::Result) }
287302
def curl_output(*args, **options)
288-
super(*_curl_args, *args, **_curl_opts, **options)
303+
super(*_curl_args, *expand_deferred_environment_args(args), **_curl_opts, **options)
289304
end
290305

291306
sig {
@@ -294,6 +309,6 @@ def curl_output(*args, **options)
294309
}
295310
def curl(*args, print_stdout: true, **options)
296311
options[:connect_timeout] = 15 unless mirrors.empty?
297-
super(*_curl_args, *args, **_curl_opts, **command_output_options, **options)
312+
super(*_curl_args, *expand_deferred_environment_args(args), **_curl_opts, **command_output_options, **options)
298313
end
299314
end

Library/Homebrew/downloadable.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,11 @@ def downloader
134134
raise ArgumentError, "attempted to use a `Downloadable` without a URL!" if primary_url.blank?
135135

136136
download_strategy.new(primary_url, download_name, version,
137-
mirrors:, cache:, **T.must(@url).specs)
137+
mirrors:, cache:, **T.must(@url).specs).tap do |downloader|
138+
if AbstractDownloadStrategy.expand_deferred_environment_for?(downloader)
139+
downloader.send(:allow_deferred_environment_expansion!)
140+
end
141+
end
138142
end
139143
end
140144

Library/Homebrew/extend/ENV/sensitive.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# frozen_string_literal: true
33

44
require "env_config"
5+
require "context"
56

67
module EnvSensitive
78
extend T::Helpers
@@ -68,6 +69,7 @@ def clear_sensitive_environment_for_eval!(&block)
6869
sig { params(value: String).returns(String) }
6970
def expand_deferred_environment(value)
7071
return value unless value.include?(DEFERRED_PLACEHOLDER_PREFIX)
72+
return value unless Context.current.deferred_environment_expansion?
7173

7274
prefix = Regexp.escape(DEFERRED_PLACEHOLDER_PREFIX)
7375
suffix = Regexp.escape(DEFERRED_PLACEHOLDER_SUFFIX)

Library/Homebrew/test/ENV_spec.rb

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
subject(:env) { {}.extend(EnvActivation).extend(described_class) }
88

99
shared_examples EnvActivation do
10+
include Context
11+
1012
it "supports switching compilers" do
1113
subject.clang
1214
expect(subject["LD"]).to be_nil
@@ -188,20 +190,23 @@
188190
end
189191

190192
describe "#clear_sensitive_environment_for_eval!" do
191-
it "defers HOMEBREW_ secrets to a placeholder that expands to the real value" do
193+
it "defers HOMEBREW_ secrets to a placeholder" do
192194
subject["HOMEBREW_PRIVATE_TOKEN"] = "glpat-secret"
193195

194196
deferred = subject.clear_sensitive_environment_for_eval! { subject["HOMEBREW_PRIVATE_TOKEN"] }
195197

196198
expect(deferred).not_to eq("glpat-secret")
197199
expect(deferred).not_to be_empty
198-
expect(subject.expand_deferred_environment("PRIVATE-TOKEN: #{deferred}")).to eq("PRIVATE-TOKEN: glpat-secret")
200+
expect(subject.expand_deferred_environment("PRIVATE-TOKEN: #{deferred}")).to eq("PRIVATE-TOKEN: #{deferred}")
199201
end
200202

201203
it "never expands a non-HOMEBREW_ secret back to its real value" do
202204
subject["SECRET_TOKEN"] = "password"
203205
deferred = subject.clear_sensitive_environment_for_eval! { subject["SECRET_TOKEN"] }
204-
expect(subject.expand_deferred_environment("X: #{deferred}")).not_to include("password")
206+
207+
with_context(deferred_environment_expansion: true) do
208+
expect(subject.expand_deferred_environment("X: #{deferred}")).not_to include("password")
209+
end
205210
end
206211

207212
it "keeps HOMEBREW_GITHUB_API_TOKEN readable during eval" do
@@ -222,6 +227,16 @@
222227
it "leaves values without a deferred placeholder unchanged" do
223228
expect(subject.expand_deferred_environment("PRIVATE-TOKEN: plain")).to eq("PRIVATE-TOKEN: plain")
224229
end
230+
231+
it "expands placeholders only during download strategy fetches" do
232+
subject["HOMEBREW_PRIVATE_TOKEN"] = "glpat-secret"
233+
deferred = subject.clear_sensitive_environment_for_eval! { subject["HOMEBREW_PRIVATE_TOKEN"] }
234+
235+
with_context(deferred_environment_expansion: true) do
236+
expect(subject.expand_deferred_environment("PRIVATE-TOKEN: #{deferred}"))
237+
.to eq("PRIVATE-TOKEN: glpat-secret")
238+
end
239+
end
225240
end
226241
end
227242

Library/Homebrew/test/download_strategies/curl_spec.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,35 @@
3636

3737
after { ENV.delete("HOMEBREW_PRIVATE_TOKEN") }
3838

39-
it "expands the placeholder to the real value at download time" do
40-
expect(strategy.send(:_curl_args)).to include("PRIVATE-TOKEN: glpat-secret")
39+
it "does not expand the placeholder outside Downloadable#fetch" do
40+
expect(header).to include(EnvSensitive::DEFERRED_PLACEHOLDER_PREFIX)
41+
expect(strategy.send(:_curl_args)).to include(header)
42+
end
43+
end
44+
45+
context "with a deferred HOMEBREW_ secret in the URL" do
46+
let(:url) do
47+
ENV["HOMEBREW_PRIVATE_TOKEN"] = "glpat-secret"
48+
ENV.clear_sensitive_environment_for_eval! do
49+
"https://example.com/foo.tar.gz?private_token=#{ENV.fetch("HOMEBREW_PRIVATE_TOKEN", nil)}"
50+
end
51+
end
52+
53+
after { ENV.delete("HOMEBREW_PRIVATE_TOKEN") }
54+
55+
it "does not expand the placeholder outside Downloadable#fetch" do
56+
expect(url).to include(EnvSensitive::DEFERRED_PLACEHOLDER_PREFIX)
57+
expect(strategy).to receive(:system_command)
58+
.with(
59+
/curl/,
60+
hash_including(args: array_including(url)),
61+
)
62+
.at_least(:once)
63+
.and_return(instance_double(SystemCommand::Result, success?: true, stdout: "", assert_success!: nil))
64+
65+
strategy.temporary_path.dirname.mkpath
66+
FileUtils.touch strategy.temporary_path
67+
strategy.fetch
4168
end
4269
end
4370

Library/Homebrew/test/resource_spec.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,66 @@
149149
end
150150
end
151151

152+
describe "#fetch" do
153+
let(:url) do
154+
ENV["HOMEBREW_PRIVATE_TOKEN"] = "glpat-secret"
155+
ENV.clear_sensitive_environment_for_eval! do
156+
"https://example.com/foo.tar.gz?private_token=#{ENV.fetch("HOMEBREW_PRIVATE_TOKEN", nil)}"
157+
end
158+
end
159+
let(:headers) do
160+
{
161+
"accept-ranges" => "bytes",
162+
"content-length" => "37182",
163+
}
164+
end
165+
166+
before do
167+
resource.url(url)
168+
allow(resource.downloader).to receive(:curl_headers).with(any_args)
169+
.and_return({ responses: [{ headers: }] })
170+
end
171+
172+
after do
173+
ENV.delete("HOMEBREW_PRIVATE_TOKEN")
174+
resource.clear_cache
175+
end
176+
177+
it "expands deferred environment placeholders while downloading" do
178+
expect(url).to include(EnvSensitive::DEFERRED_PLACEHOLDER_PREFIX)
179+
expect(resource.downloader).to receive(:system_command)
180+
.with(
181+
/curl/,
182+
hash_including(args: array_including("https://example.com/foo.tar.gz?private_token=glpat-secret")),
183+
)
184+
.at_least(:once)
185+
.and_return(instance_double(SystemCommand::Result, success?: true, stdout: "", assert_success!: nil))
186+
187+
resource.downloader.temporary_path.dirname.mkpath
188+
FileUtils.touch resource.downloader.temporary_path
189+
resource.fetch(verify_download_integrity: false)
190+
end
191+
192+
it "does not expand placeholders for custom curl download strategies" do
193+
expect(url).to include(EnvSensitive::DEFERRED_PLACEHOLDER_PREFIX)
194+
resource.url(url, using: Class.new(CurlDownloadStrategy))
195+
allow(resource.downloader).to receive(:curl_headers).with(any_args)
196+
.and_return({ responses: [{ headers: }] })
197+
198+
expect(resource.downloader).to receive(:system_command)
199+
.with(
200+
/curl/,
201+
hash_including(args: array_including(url)),
202+
)
203+
.at_least(:once)
204+
.and_return(instance_double(SystemCommand::Result, success?: true, stdout: "", assert_success!: nil))
205+
206+
resource.downloader.temporary_path.dirname.mkpath
207+
FileUtils.touch resource.downloader.temporary_path
208+
resource.fetch(verify_download_integrity: false)
209+
end
210+
end
211+
152212
describe "#stage" do
153213
let(:last_modified) { Time.utc(2026, 5, 6, 13, 43, 5) }
154214
let(:tarball) { TEST_FIXTURE_DIR/"tarballs/testball-0.1.tbz" }

Library/Homebrew/test/utils/curl_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,22 @@
651651

652652
curl_output("--location", "https://example.com/example.tar.gz")
653653
end
654+
655+
it "does not expand deferred environment placeholders" do
656+
ENV["HOMEBREW_PRIVATE_TOKEN"] = "glpat-secret"
657+
url = ENV.clear_sensitive_environment_for_eval! do
658+
"https://example.com/example.tar.gz?private_token=#{ENV.fetch("HOMEBREW_PRIVATE_TOKEN", nil)}"
659+
end
660+
661+
expect(self).to receive(:system_command).with(
662+
/curl/,
663+
hash_including(args: array_including(url)),
664+
).and_return(
665+
instance_double(SystemCommand::Result, success?: true, stdout: ""),
666+
)
667+
668+
curl_output(url)
669+
end
654670
end
655671

656672
describe "::http_status_ok?" do

0 commit comments

Comments
 (0)