| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with this |
| # work for additional information regarding copyright ownership. The ASF |
| # licenses this file to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| |
| require 'net/http' |
| # PATCH: On Windows, Net::SSH 2.0.2 attempts to load the Pageant DLLs which break on JRuby. |
| $LOADED_FEATURES << 'net/ssh/authentication/pageant.rb' if RUBY_PLATFORM =~ /java/ |
| Net.autoload :SSH, 'net/ssh' |
| Net.autoload :SFTP, 'net/sftp' |
| autoload :CGI, 'cgi' |
| require 'digest/md5' |
| require 'digest/sha1' |
| autoload :ProgressBar, 'buildr/core/progressbar' |
| |
| # Not quite open-uri, but similar. Provides read and write methods for the resource represented by the URI. |
| # Currently supports reads for URI::HTTP and writes for URI::SFTP. Also provides convenience methods for |
| # downloads and uploads. |
| module URI |
| |
| # Raised when trying to read/download a resource that doesn't exist. |
| class NotFoundError < RuntimeError |
| end |
| |
| # How many bytes to read/write at once. Do not change without checking BUILDR-214 first. |
| RW_CHUNK_SIZE = 128 * 1024 #:nodoc: |
| |
| class << self |
| |
| # :call-seq: |
| # read(uri, options?) => content |
| # read(uri, options?) { |chunk| ... } |
| # |
| # Reads from the resource behind this URI. The first form returns the content of the resource, |
| # the second form yields to the block with each chunk of content (usually more than one). |
| # |
| # For example: |
| # File.open 'image.jpg', 'w' do |file| |
| # URI.read('http://example.com/image.jpg') { |chunk| file.write chunk } |
| # end |
| # Shorter version: |
| # File.open('image.jpg', 'w') { |file| file.write URI.read('http://example.com/image.jpg') } |
| # |
| # Supported options: |
| # * :modified -- Only download if file modified since this timestamp. Returns nil if not modified. |
| # * :progress -- Show the progress bar while reading. |
| def read(uri, options = nil, &block) |
| uri = URI.parse(uri.to_s) unless URI === uri |
| uri.read options, &block |
| end |
| |
| # :call-seq: |
| # download(uri, target, options?) |
| # |
| # Downloads the resource to the target. |
| # |
| # The target may be a file name (string or task), in which case the file is created from the resource. |
| # The target may also be any object that responds to +write+, e.g. File, StringIO, Pipe. |
| # |
| # Use the progress bar when running in verbose mode. |
| def download(uri, target, options = nil) |
| uri = URI.parse(uri.to_s) unless URI === uri |
| uri.download target, options |
| end |
| |
| # :call-seq: |
| # write(uri, content, options?) |
| # write(uri, options?) { |bytes| .. } |
| # |
| # Writes to the resource behind the URI. The first form writes the content from a string or an object |
| # that responds to +read+ and optionally +size+. The second form writes the content by yielding to the |
| # block. Each yield should return up to the specified number of bytes, the last yield returns nil. |
| # |
| # For example: |
| # File.open 'killer-app.jar', 'rb' do |file| |
| # write('sftp://localhost/jars/killer-app.jar') { |chunk| file.read(chunk) } |
| # end |
| # Or: |
| # write 'sftp://localhost/jars/killer-app.jar', File.read('killer-app.jar') |
| # |
| # Supported options: |
| # * :progress -- Show the progress bar while reading. |
| def write(uri, *args, &block) |
| uri = URI.parse(uri.to_s) unless URI === uri |
| uri.write *args, &block |
| end |
| |
| # :call-seq: |
| # upload(uri, source, options?) |
| # |
| # Uploads from source to the resource. |
| # |
| # The source may be a file name (string or task), in which case the file is uploaded to the resource. |
| # The source may also be any object that responds to +read+ (and optionally +size+), e.g. File, StringIO, Pipe. |
| # |
| # Use the progress bar when running in verbose mode. |
| def upload(uri, source, options = nil) |
| uri = URI.parse(uri.to_s) unless URI === uri |
| uri.upload source, options |
| end |
| |
| end |
| |
| class Generic |
| |
| # :call-seq: |
| # read(options?) => content |
| # read(options?) { |chunk| ... } |
| # |
| # Reads from the resource behind this URI. The first form returns the content of the resource, |
| # the second form yields to the block with each chunk of content (usually more than one). |
| # |
| # For options, see URI::read. |
| def read(options = nil, &block) |
| fail 'This protocol doesn\'t support reading (yet, how about helping by implementing it?)' |
| end |
| |
| # :call-seq: |
| # download(target, options?) |
| # |
| # Downloads the resource to the target. |
| # |
| # The target may be a file name (string or task), in which case the file is created from the resource. |
| # The target may also be any object that responds to +write+, e.g. File, StringIO, Pipe. |
| # |
| # Use the progress bar when running in verbose mode. |
| def download(target, options = nil) |
| case target |
| when Rake::Task |
| download target.name, options |
| when String |
| # If download breaks we end up with a partial file which is |
| # worse than not having a file at all, so download to temporary |
| # file and then move over. |
| modified = File.stat(target).mtime if File.exist?(target) |
| temp = Tempfile.new(File.basename(target)) |
| temp.binmode |
| written = false |
| read({:progress=>verbose}.merge(options || {}).merge(:modified=>modified)) { |chunk| written = true; temp.write chunk } |
| temp.close |
| mkpath File.dirname(target) |
| # Only attempt to override file if it was actually written to, i.e. "HTTP Not Modified" was not returned. |
| mv temp.path, target if written |
| when File |
| read({:progress=>verbose}.merge(options || {}).merge(:modified=>target.mtime)) { |chunk| target.write chunk } |
| target.flush |
| else |
| raise ArgumentError, 'Expecting a target that is either a file name (string, task) or object that responds to write (file, pipe).' unless target.respond_to?(:write) |
| read({:progress=>verbose}.merge(options || {})) { |chunk| target.write chunk } |
| target.flush |
| end |
| end |
| |
| # :call-seq: |
| # write(content, options?) |
| # write(options?) { |bytes| .. } |
| # |
| # Writes to the resource behind the URI. The first form writes the content from a string or an object |
| # that responds to +read+ and optionally +size+. The second form writes the content by yielding to the |
| # block. Each yield should return up to the specified number of bytes, the last yield returns nil. |
| # |
| # For options, see URI::write. |
| def write(*args, &block) |
| options = args.pop if Hash === args.last |
| options ||= {} |
| if String === args.first |
| ios = StringIO.new(args.first, 'r') |
| write(options.merge(:size=>args.first.size)) { |bytes| ios.read(bytes) } |
| elsif args.first.respond_to?(:read) |
| size = args.first.size rescue nil |
| write({:size=>size}.merge(options)) { |bytes| args.first.read(bytes) } |
| elsif args.empty? && block |
| write_internal options, &block |
| else |
| raise ArgumentError, 'Either give me the content, or pass me a block, otherwise what would I upload?' |
| end |
| end |
| |
| # :call-seq: |
| # upload(source, options?) |
| # |
| # Uploads from source to the resource. |
| # |
| # The source may be a file name (string or task), in which case the file is uploaded to the resource. |
| # If the source is a directory, uploads all files inside the directory (including nested directories). |
| # The source may also be any object that responds to +read+ (and optionally +size+), e.g. File, StringIO, Pipe. |
| # |
| # Use the progress bar when running in verbose mode. |
| def upload(source, options = nil) |
| source = source.name if Rake::Task === source |
| options ||= {} |
| if String === source |
| raise NotFoundError, 'No source file/directory to upload.' unless File.exist?(source) |
| if File.directory?(source) |
| Dir.glob("#{source}/**/*").reject { |file| File.directory?(file) }.each do |file| |
| uri = self + (File.join(self.path, file.sub(source, ''))) |
| uri.upload file, {:digests=>[]}.merge(options) |
| end |
| else |
| File.open(source, 'rb') { |input| upload input, options } |
| end |
| elsif source.respond_to?(:read) |
| digests = (options[:digests] || [:md5, :sha1]). |
| inject({}) { |hash, name| hash[name] = name.to_s == 'sha512' ? Digest::SHA2.new(512) : Digest.const_get(name.to_s.upcase).new ; hash} |
| size = source.stat.size rescue nil |
| write (options).merge(:progress=>verbose && size, :size=>size) do |bytes| |
| source.read(bytes).tap do |chunk| |
| digests.values.each { |digest| digest << chunk } if chunk |
| end |
| end |
| digests.each do |key, digest| |
| self.merge("#{self.path}.#{key}").write digest.hexdigest, |
| (options).merge(:progress=>false) |
| end |
| else |
| raise ArgumentError, 'Expecting source to be a file name (string, task) or any object that responds to read (file, pipe).' |
| end |
| end |
| |
| protected |
| |
| # :call-seq: |
| # with_progress_bar(show, file_name, size) { |progress| ... } |
| # |
| # Displays a progress bar while executing the block. The first argument must be true for the |
| # progress bar to show (TTY output also required), as a convenient for selectively using the |
| # progress bar from a single block. |
| # |
| # The second argument provides a filename to display, the third its size in bytes. |
| # |
| # The block is yielded with a progress object that implements a single method. |
| # Call << for each block of bytes down/uploaded. |
| def with_progress_bar(show, file_name, size, &block) #:nodoc: |
| options = { :total=>size || 0, :title=>file_name } |
| options[:hidden] = true unless show |
| ProgressBar.start options, &block |
| end |
| |
| # :call-seq: |
| # proxy_uri => URI? |
| # |
| # Returns the proxy server to use. Obtains the proxy from the relevant environment variable (e.g. HTTP_PROXY). |
| # Supports exclusions based on host name and port number from environment variable NO_PROXY. |
| def proxy_uri |
| proxy = ENV["#{scheme.upcase}_PROXY"] |
| proxy = URI.parse(proxy) if String === proxy |
| excludes = ENV['NO_PROXY'].to_s.split(/\s*,\s*/).compact |
| excludes = excludes.map { |exclude| exclude =~ /:\d+$/ ? exclude : "#{exclude}:*" } |
| return proxy unless excludes.any? { |exclude| File.fnmatch(exclude, "#{host}:#{port}") } |
| end |
| |
| def write_internal(options, &block) #:nodoc: |
| fail 'This protocol doesn\'t support writing (yet, how about helping by implementing it?)' |
| end |
| |
| end |
| |
| |
| class HTTP #:nodoc: |
| |
| # See URI::Generic#read |
| def read(options = nil, &block) |
| options ||= {} |
| connect do |http| |
| trace "Requesting #{self}" |
| headers = {} |
| headers['If-Modified-Since'] = CGI.rfc1123_date(options[:modified].utc) if options[:modified] |
| headers['Cache-Control'] = 'no-cache' |
| headers['User-Agent'] = "Buildr-#{Buildr::VERSION}" |
| request = Net::HTTP::Get.new(request_uri.empty? ? '/' : request_uri, headers) |
| request.basic_auth URI.decode(self.user), URI.decode(self.password) if self.user |
| http.verify_mode = ::OpenSSL::SSL.const_get(ENV['SSL_VERIFY_MODE']) if ENV['SSL_VERIFY_MODE'] |
| http.ca_path = ENV['SSL_CA_CERTS'] if ENV['SSL_CA_CERTS'] |
| http.request request do |response| |
| case response |
| when Net::HTTPNotModified |
| # No modification, nothing to do. |
| trace 'Not modified since last download' |
| return nil |
| when Net::HTTPRedirection |
| # Try to download from the new URI, handle relative redirects. |
| trace "Redirected to #{response['Location']}" |
| rself = self + URI.parse(response['Location']) |
| rself.user, rself.password = self.user, self.password |
| return rself.read(options, &block) |
| when Net::HTTPOK |
| info "Downloading #{self}" |
| result = nil |
| with_progress_bar options[:progress], path.split('/').last, response.content_length do |progress| |
| if block |
| response.read_body do |chunk| |
| block.call chunk |
| progress << chunk |
| end |
| else |
| result = '' |
| response.read_body do |chunk| |
| result << chunk |
| progress << chunk |
| end |
| end |
| end |
| return result |
| when Net::HTTPUnauthorized |
| raise NotFoundError, "Looking for #{self} but repository says Unauthorized/401." |
| when Net::HTTPNotFound |
| raise NotFoundError, "Looking for #{self} and all I got was a 404!" |
| else |
| raise RuntimeError, "Failed to download #{self}: #{response.message}" |
| end |
| end |
| end |
| end |
| |
| private |
| |
| def write_internal(options, &block) #:nodoc: |
| options ||= {} |
| connect do |http| |
| http.read_timeout = 500 |
| trace "Uploading to #{path}" |
| content = StringIO.new |
| while chunk = yield(RW_CHUNK_SIZE) |
| content << chunk |
| end |
| headers = { 'Content-MD5'=>Digest::MD5.hexdigest(content.string), 'Content-Type'=>'application/octet-stream', 'User-Agent'=>"Buildr-#{Buildr::VERSION}" } |
| request = Net::HTTP::Put.new(request_uri.empty? ? '/' : request_uri, headers) |
| request.basic_auth URI.decode(self.user), URI.decode(self.password) if self.user |
| response = nil |
| with_progress_bar options[:progress], path.split('/').last, content.size do |progress| |
| request.content_length = content.size |
| content.rewind |
| stream = Object.new |
| class << stream ; self ;end.send :define_method, :read do |*args| |
| bytes = content.read(*args) |
| progress << bytes if bytes |
| bytes |
| end |
| request.body_stream = stream |
| response = http.request(request) |
| end |
| |
| case response |
| when Net::HTTPRedirection |
| # Try to download from the new URI, handle relative redirects. |
| trace "Redirected to #{response['Location']}" |
| content.rewind |
| return (self + URI.parse(response['location'])).write_internal(options) { |bytes| content.read(bytes) } |
| when Net::HTTPSuccess |
| else |
| raise RuntimeError, "Failed to upload #{self}: #{response.message}" |
| end |
| end |
| end |
| |
| def connect |
| if proxy = proxy_uri |
| proxy = URI.parse(proxy) if String === proxy |
| http = Net::HTTP.new(host, port, proxy.host, proxy.port, proxy.user, proxy.password) |
| else |
| http = Net::HTTP.new(host, port) |
| end |
| if self.instance_of? URI::HTTPS |
| require 'net/https' |
| http.use_ssl = true |
| end |
| yield http |
| end |
| |
| end |
| |
| |
| class SFTP < Generic #:nodoc: |
| |
| DEFAULT_PORT = 22 |
| COMPONENT = [ :scheme, :userinfo, :host, :port, :path ].freeze |
| |
| class << self |
| # Caching of passwords, so we only need to ask once. |
| def passwords |
| @passwords ||= {} |
| end |
| end |
| |
| def initialize(*arg) |
| super |
| end |
| |
| def read(options = {}, &block) |
| # SSH options are based on the username/password from the URI. |
| ssh_options = { :port=>port, :password=>password }.merge(options[:ssh_options] || {}) |
| ssh_options[:password] ||= SFTP.passwords[host] |
| begin |
| trace "Connecting to #{host}" |
| if block |
| result = nil |
| else |
| result = '' |
| block = lambda { |chunk| result << chunk } |
| end |
| Net::SFTP.start(host, user, ssh_options) do |sftp| |
| SFTP.passwords[host] = ssh_options[:password] |
| trace 'connected' |
| |
| with_progress_bar options[:progress] && options[:size], path.split('/').last, options[:size] || 0 do |progress| |
| trace "Downloading from #{path}" |
| sftp.file.open(path, 'r') do |file| |
| while chunk = file.read(RW_CHUNK_SIZE) |
| block.call chunk |
| progress << chunk |
| break if chunk.size < RW_CHUNK_SIZE |
| end |
| end |
| end |
| end |
| return result |
| rescue Net::SSH::AuthenticationFailed=>ex |
| # Only if running with console, prompt for password. |
| if !ssh_options[:password] && $stdout.isatty |
| password = Buildr::Console.ask_password("Password for #{host}:") { |q| q.echo = '*' } |
| ssh_options[:password] = password |
| retry |
| end |
| raise |
| end |
| end |
| |
| protected |
| |
| def write_internal(options, &block) #:nodoc: |
| # SSH options are based on the username/password from the URI. |
| ssh_options = { :port=>port, :password=>password }.merge(options[:ssh_options] || {}) |
| ssh_options[:password] ||= SFTP.passwords[host] |
| begin |
| trace "Connecting to #{host}" |
| Net::SFTP.start(host, user, ssh_options) do |sftp| |
| SFTP.passwords[host] = ssh_options[:password] |
| trace 'Connected' |
| |
| # To create a path, we need to create all its parent. We use realpath to determine if |
| # the path already exists, otherwise mkdir fails. |
| trace "Creating path #{path}" |
| File.dirname(path).split('/').reject(&:empty?).inject('/') do |base, part| |
| combined = base + part |
| sftp.close(sftp.opendir!(combined)) rescue sftp.mkdir! combined, {} |
| "#{combined}/" |
| end |
| |
| with_progress_bar options[:progress] && options[:size], path.split('/').last, options[:size] || 0 do |progress| |
| trace "Uploading to #{path}" |
| sftp.file.open(path, 'w') do |file| |
| while chunk = yield(RW_CHUNK_SIZE) |
| file.write chunk |
| progress << chunk |
| end |
| sftp.setstat(path, :permissions => options[:permissions]) if options[:permissions] |
| end |
| end |
| end |
| rescue Net::SSH::AuthenticationFailed=>ex |
| # Only if running with console, prompt for password. |
| if !ssh_options[:password] && $stdout.isatty |
| password = Buildr::Console.ask_password("Password for #{host}:") { |q| q.echo = '*' } |
| ssh_options[:password] = password |
| retry |
| end |
| raise |
| end |
| end |
| |
| end |
| |
| @@schemes['SFTP'] = SFTP |
| |
| |
| # File URL. Keep in mind that file URLs take the form of <code>file://host/path</code>, although the host |
| # is not used, so typically all you will see are three backslashes. This methods accept common variants, |
| # like <code>file:/path</code> but always returns a valid URL. |
| class FILE < Generic |
| |
| COMPONENT = [ :host, :path ].freeze |
| |
| def upload(source, options = nil) |
| super |
| if File === source then |
| File.chmod(source.stat.mode, real_path) |
| end |
| end |
| |
| def initialize(*args) |
| super |
| # file:something (opaque) becomes file:///something |
| if path.nil? |
| set_path "/#{opaque}" |
| unless opaque.nil? |
| set_opaque nil |
| warn "#{caller[2]}: We'll accept this URL, but just so you know, it needs three slashes, as in: #{to_s}" |
| end |
| end |
| # Sadly, file://something really means file://something/ (something being server) |
| set_path '/' if path.empty? |
| |
| # On windows, file://c:/something is not a valid URL, but people do it anyway, so if we see a drive-as-host, |
| # we'll just be nice enough to fix it. (URI actually strips the colon here) |
| if host =~ /^[a-zA-Z]$/ |
| set_path "/#{host}:#{path}" |
| set_host nil |
| end |
| end |
| |
| # See URI::Generic#read |
| def read(options = nil, &block) |
| options ||= {} |
| raise ArgumentError, 'Either you\'re attempting to read a file from another host (which we don\'t support), or you used two slashes by mistake, where you should have file:///<path>.' if host |
| |
| path = real_path |
| # TODO: complain about clunky URLs |
| raise NotFoundError, "Looking for #{self} and can't find it." unless File.exists?(path) |
| raise NotFoundError, "Looking for the file #{self}, and it happens to be a directory." if File.directory?(path) |
| File.open path, 'rb' do |input| |
| with_progress_bar options[:progress], path.split('/').last, input.stat.size do |progress| |
| block ? block.call(input.read) : input.read |
| end |
| end |
| end |
| |
| def to_s |
| "file://#{host}#{path}" |
| end |
| |
| # Returns the file system path based that corresponds to the URL path. |
| # On windows this method strips the leading slash off of the path. |
| # On all platforms this method unescapes the URL path. |
| def real_path #:nodoc: |
| real_path = Buildr::Util.win_os? && path =~ /^\/[a-zA-Z]:\// ? path[1..-1] : path |
| URI.unescape(real_path) |
| end |
| |
| protected |
| |
| def write_internal(options, &block) #:nodoc: |
| raise ArgumentError, 'Either you\'re attempting to write a file to another host (which we don\'t support), or you used two slashes by mistake, where you should have file:///<path>.' if host |
| temp = Tempfile.new(File.basename(path)) |
| temp.binmode |
| with_progress_bar options[:progress] && options[:size], path.split('/').last, options[:size] || 0 do |progress| |
| while chunk = yield(RW_CHUNK_SIZE) |
| temp.write chunk |
| progress << chunk |
| end |
| end |
| temp.close |
| mkpath File.dirname(real_path) |
| mv temp.path, real_path |
| real_path |
| end |
| |
| @@schemes['FILE'] = FILE |
| |
| end |
| |
| end |