Add md5 and sha1 checksum support to Downloader.

Allows checksum validation on downloaded files via Util::Downloader
using MD5 and/or SHA1 checksums. This also integrates checksum validation
support with the shell provisioner for downloaded remote files.
This commit is contained in:
Chris Roberts 2016-11-09 16:00:09 -08:00
parent e494320ba2
commit da45ca707c
8 changed files with 258 additions and 3 deletions

View File

@ -340,6 +340,10 @@ module Vagrant
error_key(:downloader_interrupted) error_key(:downloader_interrupted)
end end
class DownloaderChecksumError < VagrantError
error_key(:downloader_checksum_error)
end
class EnvInval < VagrantError class EnvInval < VagrantError
error_key(:env_inval) error_key(:env_inval)
end end

View File

@ -1,7 +1,8 @@
require "uri" require "uri"
require "log4r" require "log4r"
require "digest/md5"
require "digest/sha1"
require "vagrant/util/busy" require "vagrant/util/busy"
require "vagrant/util/platform" require "vagrant/util/platform"
require "vagrant/util/subprocess" require "vagrant/util/subprocess"
@ -18,6 +19,12 @@ module Vagrant
# Vagrant/1.7.4 (+https://www.vagrantup.com; ruby2.1.0) # Vagrant/1.7.4 (+https://www.vagrantup.com; ruby2.1.0)
USER_AGENT = "Vagrant/#{VERSION} (+https://www.vagrantup.com; #{RUBY_ENGINE}#{RUBY_VERSION})".freeze USER_AGENT = "Vagrant/#{VERSION} (+https://www.vagrantup.com; #{RUBY_ENGINE}#{RUBY_VERSION})".freeze
# Supported file checksum
CHECKSUM_MAP = {
:md5 => Digest::MD5,
:sha1 => Digest::SHA1
}.freeze
attr_reader :source attr_reader :source
attr_reader :destination attr_reader :destination
@ -52,6 +59,10 @@ module Vagrant
@ui = options[:ui] @ui = options[:ui]
@client_cert = options[:client_cert] @client_cert = options[:client_cert]
@location_trusted = options[:location_trusted] @location_trusted = options[:location_trusted]
@checksums = {
:md5 => options[:md5],
:sha1 => options[:sha1]
}
end end
# This executes the actual download, downloading the source file # This executes the actual download, downloading the source file
@ -161,6 +172,8 @@ module Vagrant
end end
end end
validate_download!(@source, @destination, @checksums)
# Everything succeeded # Everything succeeded
true true
end end
@ -178,6 +191,47 @@ module Vagrant
protected protected
# Apply any checksum validations based on provided
# options content
#
# @param source [String] Source of file
# @param path [String, Pathname] local file path
# @param checksums [Hash] User provided options
# @option checksums [String] :md5 Compare MD5 checksum
# @option checksums [String] :sha1 Compare SHA1 checksum
# @return [Boolean]
def validate_download!(source, path, checksums)
CHECKSUM_MAP.each do |type, klass|
if checksums[type]
result = checksum_file(klass, path)
if checksums[type] != result
raise Errors::DownloaderChecksumError.new(
source: source,
path: path,
expected_checksum: checksums[type],
actual_checksum: result
)
end
end
end
true
end
# Generate checksum on given file
#
# @param digest_class [Class] Digest class to use for generating checksum
# @param path [String, Pathname] Path to file
# @return [String] hexdigest result
def checksum_file(digest_class, path)
digester = digest_class.new
File.open(path.to_s, 'rb') do |file|
while(data = file.read(1024))
digester << data
end
end
digester.hexdigest
end
def execute_curl(options, subprocess_options, &data_proc) def execute_curl(options, subprocess_options, &data_proc)
options = options.dup options = options.dup
options << subprocess_options options << subprocess_options

View File

@ -5,6 +5,8 @@ module VagrantPlugins
class Config < Vagrant.plugin("2", :config) class Config < Vagrant.plugin("2", :config)
attr_accessor :inline attr_accessor :inline
attr_accessor :path attr_accessor :path
attr_accessor :md5
attr_accessor :sha1
attr_accessor :env attr_accessor :env
attr_accessor :upload_path attr_accessor :upload_path
attr_accessor :args attr_accessor :args
@ -19,6 +21,8 @@ module VagrantPlugins
@args = UNSET_VALUE @args = UNSET_VALUE
@inline = UNSET_VALUE @inline = UNSET_VALUE
@path = UNSET_VALUE @path = UNSET_VALUE
@md5 = UNSET_VALUE
@sha1 = UNSET_VALUE
@env = UNSET_VALUE @env = UNSET_VALUE
@upload_path = UNSET_VALUE @upload_path = UNSET_VALUE
@privileged = UNSET_VALUE @privileged = UNSET_VALUE
@ -33,6 +37,8 @@ module VagrantPlugins
@args = nil if @args == UNSET_VALUE @args = nil if @args == UNSET_VALUE
@inline = nil if @inline == UNSET_VALUE @inline = nil if @inline == UNSET_VALUE
@path = nil if @path == UNSET_VALUE @path = nil if @path == UNSET_VALUE
@md5 = nil if @md5 == UNSET_VALUE
@sha1 = nil if @sha1 == UNSET_VALUE
@env = {} if @env == UNSET_VALUE @env = {} if @env == UNSET_VALUE
@upload_path = "/tmp/vagrant-shell" if @upload_path == UNSET_VALUE @upload_path = "/tmp/vagrant-shell" if @upload_path == UNSET_VALUE
@privileged = true if @privileged == UNSET_VALUE @privileged = true if @privileged == UNSET_VALUE

View File

@ -177,7 +177,12 @@ module VagrantPlugins
download_path.delete if download_path.file? download_path.delete if download_path.file?
begin begin
Vagrant::Util::Downloader.new(config.path, download_path).download! Vagrant::Util::Downloader.new(
config.path,
download_path,
md5: config.md5,
sha1: config.sha1
).download!
ext = File.extname(config.path) ext = File.extname(config.path)
script = download_path.read script = download_path.read
ensure ensure

View File

@ -734,6 +734,13 @@ en:
downloader_interrupted: |- downloader_interrupted: |-
The download was interrupted by an external signal. It did not The download was interrupted by an external signal. It did not
complete. complete.
downloader_checksum_error: |-
The calculated checksum of the requested file does not match the expected
checksum!
File source: %{source}
Expected checksum: %{expected_checksum}
Calculated checksum: %{actual_checksum}
env_inval: |- env_inval: |-
Vagrant received an "EINVAL" error while attempting to set some Vagrant received an "EINVAL" error while attempting to set some
environment variables. This is usually caused by the total size of your environment variables. This is usually caused by the total size of your

View File

@ -2,14 +2,19 @@ require File.expand_path("../../../../base", __FILE__)
require Vagrant.source_root.join("plugins/provisioners/shell/provisioner") require Vagrant.source_root.join("plugins/provisioners/shell/provisioner")
describe "Vagrant::Shell::Provisioner" do describe "Vagrant::Shell::Provisioner" do
include_context "unit"
let(:env){ isolated_environment }
let(:machine) { let(:machine) {
double(:machine).tap { |machine| double(:machine, env: env, id: "ID").tap { |machine|
machine.stub_chain(:config, :vm, :communicator).and_return(:not_winrm) machine.stub_chain(:config, :vm, :communicator).and_return(:not_winrm)
machine.stub_chain(:communicate, :tap) {} machine.stub_chain(:communicate, :tap) {}
} }
} }
before do
allow(env).to receive(:tmp_path).and_return(Pathname.new("/dev/null"))
end
context "with a script that contains invalid us-ascii byte sequences" do context "with a script that contains invalid us-ascii byte sequences" do
let(:config) { let(:config) {
@ -37,4 +42,73 @@ describe "Vagrant::Shell::Provisioner" do
}.not_to raise_error }.not_to raise_error
end end
end end
context "with remote script" do
context "that does not have matching sha1 checksum" do
let(:config) {
double(
:config,
:args => "doesn't matter",
:env => {},
:upload_path => "arbitrary",
:remote? => true,
:path => "http://example.com/script.sh",
:binary => false,
:md5 => nil,
:sha1 => 'EXPECTED_VALUE'
)
}
let(:file){ double("file") }
let(:digest){ double("digest") }
before do
Vagrant::Util::Downloader.any_instance.should_receive(:execute_curl).and_return(true)
expect(File).to receive(:open).with(%r{/dev/null/.+}, "rb").and_yield(file).once
allow(File).to receive(:open).and_call_original
expect(file).to receive(:read).and_return(nil)
expect(Digest::SHA1).to receive(:new).and_return(digest)
expect(digest).to receive(:hexdigest).and_return('INVALID_VALUE')
end
it "should raise an exception" do
vsp = VagrantPlugins::Shell::Provisioner.new(machine, config)
expect{ vsp.provision }.to raise_error(Vagrant::Errors::DownloaderChecksumError)
end
end
context "that does not have matching md5 checksum" do
let(:config) {
double(
:config,
:args => "doesn't matter",
:env => {},
:upload_path => "arbitrary",
:remote? => true,
:path => "http://example.com/script.sh",
:binary => false,
:md5 => 'EXPECTED_VALUE',
:sha1 => nil
)
}
let(:file){ double("file") }
let(:digest){ double("digest") }
before do
Vagrant::Util::Downloader.any_instance.should_receive(:execute_curl).and_return(true)
expect(File).to receive(:open).with(%r{/dev/null/.+}, "rb").and_yield(file).once
allow(File).to receive(:open).and_call_original
expect(file).to receive(:read).and_return(nil)
expect(Digest::MD5).to receive(:new).and_return(digest)
expect(digest).to receive(:hexdigest).and_return('INVALID_VALUE')
end
it "should raise an exception" do
vsp = VagrantPlugins::Shell::Provisioner.new(machine, config)
expect{ vsp.provision }.to raise_error(Vagrant::Errors::DownloaderChecksumError)
end
end
end
end end

View File

@ -93,6 +93,107 @@ describe Vagrant::Util::Downloader do
expect(subject.download!).to be_true expect(subject.download!).to be_true
end end
end end
context "with checksum" do
let(:checksum_expected_value){ 'MD5_CHECKSUM_VALUE' }
let(:checksum_invalid_value){ 'INVALID_VALUE' }
let(:digest){ double("digest") }
let(:file){ double("file") }
before do
allow(digest).to receive(:<<)
allow(File).to receive(:open).and_yield(file)
allow(file).to receive(:read).and_return(nil)
end
[Digest::MD5, Digest::SHA1].each do |klass|
short_name = klass.to_s.split("::").last.downcase
context "using #{short_name} digest" do
subject { described_class.new(source, destination, short_name.to_sym => checksum_expected_value) }
context "that matches expected value" do
before do
expect(klass).to receive(:new).and_return(digest)
expect(digest).to receive(:hexdigest).and_return(checksum_expected_value)
end
it "should not raise an exception" do
expect(subject.download!).to be_true
end
end
context "that does not match expected value" do
before do
expect(klass).to receive(:new).and_return(digest)
expect(digest).to receive(:hexdigest).and_return(checksum_invalid_value)
end
it "should raise an exception" do
expect{ subject.download! }.to raise_error(Vagrant::Errors::DownloaderChecksumError)
end
end
end
end
context "using both md5 and sha1 digests" do
context "that both match expected values" do
subject { described_class.new(source, destination, md5: checksum_expected_value, sha1: checksum_expected_value) }
before do
expect(Digest::MD5).to receive(:new).and_return(digest)
expect(Digest::SHA1).to receive(:new).and_return(digest)
expect(digest).to receive(:hexdigest).and_return(checksum_expected_value).exactly(2).times
end
it "should not raise an exception" do
expect(subject.download!).to be_true
end
end
context "that only sha1 matches expected value" do
subject { described_class.new(source, destination, md5: checksum_invalid_value, sha1: checksum_expected_value) }
before do
allow(Digest::MD5).to receive(:new).and_return(digest)
allow(Digest::SHA1).to receive(:new).and_return(digest)
expect(digest).to receive(:hexdigest).and_return(checksum_expected_value).at_least(:once)
end
it "should raise an exception" do
expect{ subject.download! }.to raise_error(Vagrant::Errors::DownloaderChecksumError)
end
end
context "that only md5 matches expected value" do
subject { described_class.new(source, destination, md5: checksum_expected_value, sha1: checksum_invalid_value) }
before do
allow(Digest::MD5).to receive(:new).and_return(digest)
allow(Digest::SHA1).to receive(:new).and_return(digest)
expect(digest).to receive(:hexdigest).and_return(checksum_expected_value).at_least(:once)
end
it "should raise an exception" do
expect{ subject.download! }.to raise_error(Vagrant::Errors::DownloaderChecksumError)
end
end
context "that none match expected value" do
subject { described_class.new(source, destination, md5: checksum_invalid_value, sha1: checksum_invalid_value) }
before do
allow(Digest::MD5).to receive(:new).and_return(digest)
allow(Digest::SHA1).to receive(:new).and_return(digest)
expect(digest).to receive(:hexdigest).and_return(checksum_expected_value).at_least(:once)
end
it "should raise an exception" do
expect{ subject.download! }.to raise_error(Vagrant::Errors::DownloaderChecksumError)
end
end
end
end
end end
describe "#head" do describe "#head" do

View File

@ -79,6 +79,10 @@ The remainder of the available options are optional:
enable auto-login for Windows as the user must be logged in for interactive enable auto-login for Windows as the user must be logged in for interactive
mode to work. mode to work.
* `md5` (string) - MD5 checksum used to validate remotely downloaded shell files.
* `sha1` (string) - SHA1 checksum used to validate remotely downloaded shell files.
<a name="inline-scripts"></a> <a name="inline-scripts"></a>
## Inline Scripts ## Inline Scripts