Skip to content

Commit 34dfced

Browse files
authored
Merge pull request #329 from C24-AK/feature/file-etag-support
Feature: file etag support
2 parents 83350fe + 4ca7e0d commit 34dfced

File tree

9 files changed

+237
-11
lines changed

9 files changed

+237
-11
lines changed

lib/puppet/defaults.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ def self.default_file_checksum_types
2626

2727
def self.valid_file_checksum_types
2828
Puppet::Util::Platform.fips_enabled? ?
29-
%w[sha256 sha256lite sha384 sha512 sha224 sha1 sha1lite mtime ctime] :
30-
%w[sha256 sha256lite sha384 sha512 sha224 sha1 sha1lite md5 md5lite mtime ctime]
29+
%w[sha256 sha256lite sha384 sha512 sha224 sha1 sha1lite mtime ctime etag] :
30+
%w[sha256 sha256lite sha384 sha512 sha224 sha1 sha1lite md5 md5lite mtime ctime etag]
3131
end
3232

3333
def self.default_cadir

lib/puppet/file_serving/http_metadata.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ def initialize(http_response, path = '/dev/null')
3535
end
3636
end
3737

38+
etag = http_response['etag']
39+
if etag && !etag.start_with?('W/')
40+
etag_value = etag.delete('"').strip
41+
case etag_value
42+
when /\A[0-9a-f]{64}\z/i
43+
@checksums[:etag] = "{sha256}#{etag_value.downcase}"
44+
when /\A[0-9a-f]{40}\z/i
45+
@checksums[:etag] = "{sha1}#{etag_value.downcase}"
46+
when /\A[0-9a-f]{32}\z/i
47+
@checksums[:etag] = "{md5}#{etag_value.downcase}"
48+
end
49+
end
50+
3851
last_modified = http_response['last-modified']
3952
if last_modified
4053
mtime = DateTime.httpdate(last_modified).to_time
@@ -51,6 +64,18 @@ def collect
5164
# Prefer the checksum_type from the indirector request options
5265
# but fall back to the alternative otherwise
5366
[@checksum_type, :sha256, :sha1, :md5, :mtime].each do |type|
67+
if type == :etag
68+
if @checksums[:etag]
69+
@checksum = @checksums[:etag]
70+
resolved = sumtype(@checksum).to_sym
71+
next if resolved == :md5 && Puppet::Util::Platform.fips_enabled?
72+
73+
@checksum_type = resolved
74+
break
75+
end
76+
next
77+
end
78+
5479
next if type == :md5 && Puppet::Util::Platform.fips_enabled?
5580

5681
@checksum_type = type

lib/puppet/type/file/checksum.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
The default checksum type is sha256."
1414

1515
# The values are defined in Puppet::Util::Checksums.known_checksum_types
16-
newvalues(:sha256, :sha256lite, :md5, :md5lite, :sha1, :sha1lite, :sha512, :sha384, :sha224, :mtime, :ctime, :none)
16+
newvalues(:sha256, :sha256lite, :md5, :md5lite, :sha1, :sha1lite, :sha512, :sha384, :sha224, :mtime, :ctime, :none, :etag)
1717

1818
defaultto do
1919
Puppet[:digest_algorithm].to_sym
@@ -47,8 +47,18 @@ def sum_stream(&block)
4747
private
4848

4949
# Return the appropriate digest algorithm with fallbacks in case puppet defaults have not
50-
# been initialized.
50+
# been initialized. When the checksum type is :etag, resolve to the actual
51+
# hash algorithm that the HTTP server's ETag represents.
5152
def digest_algorithm
52-
value || Puppet[:digest_algorithm].to_sym
53+
type = value || Puppet[:digest_algorithm].to_sym
54+
return type unless type == :etag
55+
56+
source = resource.parameter(:source)
57+
resolved = source&.metadata&.checksum_type
58+
if resolved && ![:etag, :mtime, :ctime, :none].include?(resolved)
59+
return resolved
60+
end
61+
62+
:md5
5363
end
5464
end

lib/puppet/util/checksums.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ module Puppet::Util::Checksums
1717
:sha512,
1818
:sha384,
1919
:sha224,
20-
:mtime, :ctime, :none
20+
:mtime, :ctime, :none,
21+
:etag
2122
].freeze
2223

2324
# It's not a good idea to use some of these in some contexts: for example, I
@@ -339,6 +340,25 @@ def none_stream
339340
""
340341
end
341342

343+
# ETag-based checksum delegates to md5 for local file computation.
344+
# The actual algorithm is determined at runtime by HttpMetadata#collect
345+
# based on the ETag header length.
346+
def etag(content)
347+
md5(content)
348+
end
349+
350+
def etag?(string)
351+
string =~ /^\h{32,64}$/
352+
end
353+
354+
def etag_file(filename, lite = false)
355+
md5_file(filename, lite)
356+
end
357+
358+
def etag_stream(lite = false, &block)
359+
md5_stream(lite, &block)
360+
end
361+
342362
class DigestLite
343363
def initialize(digest, lite = false)
344364
@digest = digest

spec/unit/file_serving/http_metadata_spec.rb

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,5 +113,133 @@
113113
expect( metadata.checksum ).to eq "{sha256}#{sha256}"
114114
end
115115
end
116+
117+
context "with an ETag header" do
118+
context "without checksum => etag" do
119+
let(:md5) { "f5ffec8d8d16b43d5e9ac6ad4330c445" }
120+
121+
it "does not auto-activate ETag and falls back to mtime" do
122+
http_response.add_field('ETag', %("#{md5}"))
123+
metadata = described_class.new(http_response)
124+
metadata.collect
125+
expect( metadata.checksum_type ).to eq :mtime
126+
end
127+
end
128+
129+
context "with checksum_type => etag" do
130+
context "containing an MD5 hash" do
131+
let(:md5) { "f5ffec8d8d16b43d5e9ac6ad4330c445" }
132+
133+
it "resolves to md5" do
134+
http_response.add_field('ETag', %("#{md5}"))
135+
metadata = described_class.new(http_response)
136+
metadata.checksum_type = :etag
137+
metadata.collect
138+
expect( metadata.checksum_type ).to eq :md5
139+
expect( metadata.checksum ).to eq "{md5}#{md5}"
140+
end
141+
142+
it "normalizes uppercase hex to lowercase" do
143+
http_response.add_field('ETag', %("#{md5.upcase}"))
144+
metadata = described_class.new(http_response)
145+
metadata.checksum_type = :etag
146+
metadata.collect
147+
expect( metadata.checksum ).to eq "{md5}#{md5}"
148+
end
149+
end
150+
151+
context "containing a SHA1 hash" do
152+
let(:sha1) { "01e4d15746f4274b84d740a93e04b9fd2882e3ea" }
153+
154+
it "resolves to sha1" do
155+
http_response.add_field('ETag', %("#{sha1}"))
156+
metadata = described_class.new(http_response)
157+
metadata.checksum_type = :etag
158+
metadata.collect
159+
expect( metadata.checksum_type ).to eq :sha1
160+
expect( metadata.checksum ).to eq "{sha1}#{sha1}"
161+
end
162+
end
163+
164+
context "containing a SHA256 hash" do
165+
let(:sha256) { "a3eda98259c30e1e75039c2123670c18105e1c46efb672e42ca0e4cbe77b002a" }
166+
167+
it "resolves to sha256" do
168+
http_response.add_field('ETag', %("#{sha256}"))
169+
metadata = described_class.new(http_response)
170+
metadata.checksum_type = :etag
171+
metadata.collect
172+
expect( metadata.checksum_type ).to eq :sha256
173+
expect( metadata.checksum ).to eq "{sha256}#{sha256}"
174+
end
175+
end
176+
177+
context "that is a weak ETag" do
178+
it "ignores the ETag and falls back to mtime" do
179+
http_response.add_field('ETag', 'W/"f5ffec8d8d16b43d5e9ac6ad4330c445"')
180+
metadata = described_class.new(http_response)
181+
metadata.checksum_type = :etag
182+
metadata.collect
183+
expect( metadata.checksum_type ).to eq :mtime
184+
end
185+
end
186+
187+
context "that is not a recognizable hash" do
188+
it "ignores the ETag and falls back to mtime" do
189+
http_response.add_field('ETag', '"5e8c5-27a-3e8b8840"')
190+
metadata = described_class.new(http_response)
191+
metadata.checksum_type = :etag
192+
metadata.collect
193+
expect( metadata.checksum_type ).to eq :mtime
194+
end
195+
end
196+
197+
context "when explicit checksum headers are also present" do
198+
let(:explicit_md5) { "c58989e9740a748de4f5054286faf99b" }
199+
let(:etag_md5) { "f5ffec8d8d16b43d5e9ac6ad4330c445" }
200+
201+
it "prefers the ETag over X-Checksum-Md5" do
202+
http_response.add_field('X-Checksum-Md5', explicit_md5)
203+
http_response.add_field('ETag', %("#{etag_md5}"))
204+
metadata = described_class.new(http_response)
205+
metadata.checksum_type = :etag
206+
metadata.collect
207+
expect( metadata.checksum_type ).to eq :md5
208+
expect( metadata.checksum ).to eq "{md5}#{etag_md5}"
209+
end
210+
end
211+
212+
context "with ETag and Last-Modified" do
213+
let(:md5) { "f5ffec8d8d16b43d5e9ac6ad4330c445" }
214+
let(:time) { Time.now.utc }
215+
216+
it "prefers ETag-derived md5 over mtime" do
217+
http_response.add_field('ETag', %("#{md5}"))
218+
http_response.add_field('last-modified', time.strftime("%a, %d %b %Y %T GMT"))
219+
metadata = described_class.new(http_response)
220+
metadata.checksum_type = :etag
221+
metadata.collect
222+
expect( metadata.checksum_type ).to eq :md5
223+
expect( metadata.checksum ).to eq "{md5}#{md5}"
224+
end
225+
end
226+
227+
it "skips ETag-derived md5 on FIPS platforms and falls back" do
228+
allow(Puppet::Util::Platform).to receive(:fips_enabled?).and_return(true)
229+
http_response.add_field('ETag', '"f5ffec8d8d16b43d5e9ac6ad4330c445"')
230+
metadata = described_class.new(http_response)
231+
metadata.checksum_type = :etag
232+
metadata.collect
233+
expect( metadata.checksum_type ).to eq :mtime
234+
end
235+
236+
it "falls back to other checksums when no ETag is present" do
237+
metadata = described_class.new(http_response)
238+
metadata.checksum_type = :etag
239+
metadata.collect
240+
expect( metadata.checksum_type ).to eq :mtime
241+
end
242+
end
243+
end
116244
end
117245
end

spec/unit/indirector/catalog/compiler_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def set_facts(fact_hash)
226226
allow(node.environment).to receive(:static_catalogs?).and_return(true)
227227

228228
expect { compiler.find(@request) }.to raise_error Puppet::Error,
229-
"Unable to find a common checksum type between agent 'atime.md2' and master '[:sha256, :sha256lite, :md5, :md5lite, :sha1, :sha1lite, :sha512, :sha384, :sha224, :mtime, :ctime, :none]'."
229+
"Unable to find a common checksum type between agent 'atime.md2' and master '[:sha256, :sha256lite, :md5, :md5lite, :sha1, :sha1lite, :sha512, :sha384, :sha224, :mtime, :ctime, :none, :etag]'."
230230
end
231231

232232
it "errors if checksum_type contains no shared checksum types" do
@@ -237,7 +237,7 @@ def set_facts(fact_hash)
237237
allow(node.environment).to receive(:static_catalogs?).and_return(true)
238238

239239
expect { compiler.find(@request) }.to raise_error Puppet::Error,
240-
"Unable to find a common checksum type between agent '' and master '[:sha256, :sha256lite, :md5, :md5lite, :sha1, :sha1lite, :sha512, :sha384, :sha224, :mtime, :ctime, :none]'."
240+
"Unable to find a common checksum type between agent '' and master '[:sha256, :sha256lite, :md5, :md5lite, :sha1, :sha1lite, :sha512, :sha384, :sha224, :mtime, :ctime, :none, :etag]'."
241241
end
242242

243243
it "prevents the environment from being evicted during compilation" do

spec/unit/indirector/file_metadata/http_spec.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,49 @@
8080
expect(result.checksum).to eq("{mtime}2020-01-01 08:00:00 UTC")
8181
end
8282

83+
it "does not auto-activate ETag without checksum_type => etag" do
84+
etag_md5 = "f5ffec8d8d16b43d5e9ac6ad4330c445"
85+
stub_request(:head, key)
86+
.to_return(status: 200, headers: DEFAULT_HEADERS.merge("ETag" => %("#{etag_md5}")))
87+
88+
result = model.indirection.find(key)
89+
expect(result.checksum_type).to eq(:mtime)
90+
end
91+
92+
it "uses ETag as md5 when checksum_type is etag" do
93+
etag_md5 = "f5ffec8d8d16b43d5e9ac6ad4330c445"
94+
stub_request(:head, key)
95+
.to_return(status: 200, headers: DEFAULT_HEADERS.merge("ETag" => %("#{etag_md5}")))
96+
97+
result = model.indirection.find(key, checksum_type: :etag)
98+
expect(result.checksum_type).to eq(:md5)
99+
expect(result.checksum).to eq("{md5}#{etag_md5}")
100+
end
101+
102+
it "prefers explicit X-Checksum-Sha256 over ETag when not using etag checksum" do
103+
sha256 = "a3eda98259c30e1e75039c2123670c18105e1c46efb672e42ca0e4cbe77b002a"
104+
etag_md5 = "f5ffec8d8d16b43d5e9ac6ad4330c445"
105+
stub_request(:head, key)
106+
.to_return(status: 200, headers: DEFAULT_HEADERS.merge(
107+
"X-Checksum-Sha256" => sha256,
108+
"ETag" => %("#{etag_md5}")
109+
))
110+
111+
result = model.indirection.find(key)
112+
expect(result.checksum_type).to eq(:sha256)
113+
expect(result.checksum).to eq("{sha256}#{sha256}")
114+
end
115+
116+
it "ignores weak ETags even with checksum_type => etag" do
117+
stub_request(:head, key)
118+
.to_return(status: 200, headers: DEFAULT_HEADERS.merge(
119+
"ETag" => 'W/"f5ffec8d8d16b43d5e9ac6ad4330c445"'
120+
))
121+
122+
result = model.indirection.find(key, checksum_type: :etag)
123+
expect(result.checksum_type).to eq(:mtime)
124+
end
125+
83126
it "leniently parses base64" do
84127
# Content-MD5 header is missing '==' padding
85128
stub_request(:head, key)

spec/unit/type/file_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,7 +1465,7 @@
14651465
file[:source] = source
14661466
end
14671467

1468-
Puppet::Type::File::ParameterChecksum.value_collection.values.reject {|v| v == :none}.each do |checksum_type|
1468+
Puppet::Type::File::ParameterChecksum.value_collection.values.reject {|v| v == :none || v == :etag}.each do |checksum_type|
14691469
describe "with checksum '#{checksum_type}'" do
14701470
before do
14711471
file[:checksum] = checksum_type
@@ -1591,7 +1591,7 @@
15911591
file[:content] = FILE_CONTENT
15921592
end
15931593

1594-
(Puppet::Type::File::ParameterChecksum.value_collection.values - SOURCE_ONLY_CHECKSUMS).each do |checksum_type|
1594+
(Puppet::Type::File::ParameterChecksum.value_collection.values - SOURCE_ONLY_CHECKSUMS - [:etag]).each do |checksum_type|
15951595
describe "with checksum '#{checksum_type}'" do
15961596
before do
15971597
file[:checksum] = checksum_type

spec/unit/util/checksums_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
end
1111

1212
content_sums = [:md5, :md5lite, :sha1, :sha1lite, :sha256, :sha256lite, :sha512, :sha384, :sha224]
13-
file_only = [:ctime, :mtime, :none]
13+
file_only = [:ctime, :mtime, :none, :etag]
1414

1515
content_sums.each do |sumtype|
1616
it "should be able to calculate #{sumtype} sums from strings" do

0 commit comments

Comments
 (0)