require "cgi"
require "net/http"
require "net/ssh"
require "net/sftp"
require "uri"
require "uri/sftp"
require "digest/md5"
require "digest/sha1"
require "facet/progressbar"
require "highline"
require "tempfile"
require "uri/sftp"
# Monkeypatching: SFTP never defines the mkdir method on its session or the underlying
# driver, it just redirect calls through method_missing. Rake, on the other hand, decides
# to define mkdir on Object, and so routes our calls to FileUtils.
module Net #:nodoc:all
class Session
def mkdir(path, attrs = {})
method_missing :mkdir, path, attrs
end
end
class SFTP::Protocol::Driver
def mkdir(first, path, attrs = {})
method_missing :mkdir, first, path, attrs
end
end
end
# 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
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 = nil
Tempfile.open File.basename(target) do |temp|
temp.binmode
read({:progress=>verbose}.merge(options || {}).merge(:modified=>modified)) { |chunk| temp.write chunk }
end
mkpath File.dirname(target)
File.move temp.path, target
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|
path = self.path + file.sub(source, "")
(self + path).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] = Digest.const_get(name.to_s.upcase).new ; hash }
size = source.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} #{File.basename(path)}",
(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(enable, 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(enable, file_name, size) #:nodoc:
if enable && $stdout.isatty
progress_bar = Console::ProgressBar.new(file_name, size)
# Extend the progress bar so we can display count/total.
class << progress_bar
def total()
convert_bytes(@total)
end
end
# Squeeze the filename into 30 characters.
if file_name.size > 30
base, ext = file_name.split(".")
truncated = "#{base[0..26-ext.to_s.size]}...#{ext}"
else
truncated = file_name
end
progress_bar.format = "#{truncated}: %3d%% %s %s/%s %s"
progress_bar.format = "%3d%% %s %s/%s %s"
progress_bar.format_arguments = [:percentage, :bar, :bytes, :total, :stat]
progress_bar.bar_mark = "."
begin
class << progress_bar
def <<(bytes)
inc bytes.respond_to?(:size) ? bytes.size : bytes
end
end
yield progress_bar
ensure
progress_bar.finish
end
else
progress_bar = Object.new
class << progress_bar
def <<(bytes)
end
end
yield progress_bar
end
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"] || "").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 ||= {}
headers = { "If-Modified-Since" => CGI.rfc1123_date(options[:modified].utc) } if options[:modified]
result = nil
request = lambda do |http|
puts "Requesting #{self}" if Rake.application.options.trace
http.request_get(path, headers) do |response|
case response
when Net::HTTPNotModified
# No modification, nothing to do.
puts "Not modified since last download" if Rake.application.options.trace
when Net::HTTPRedirection
# Try to download from the new URI, handle relative redirects.
puts "Redirected to #{response['Location']}" if Rake.application.options.trace
result = (self + URI.parse(response["location"])).read(options, &block)
when Net::HTTPOK
puts "Downloading #{self}" if verbose
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
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
if proxy = proxy_uri
proxy = URI.parse(proxy) if String === proxy
Net::HTTP.start(host, port, proxy.host, proxy.port, proxy.user, proxy.password) { |http| request[http] }
else
Net::HTTP.start(host, port) { |http| request[http] }
end
result
end
end
class SFTP #:nodoc:
class << self
# Caching of passwords, so we only need to ask once.
def passwords()
@passwords ||= {}
end
end
protected
def write_internal(options, &block) #:nodoc:
# SSH options are based on the username/password from the URI.
ssh_options = { :port=>port, :username=>user }.merge(options[:ssh_options] || {})
ssh_options[:password] ||= SFTP.passwords[host]
begin
puts "Connecting to #{host}" if Rake.application.options.trace
session = Net::SSH.start(host, ssh_options)
SFTP.passwords[host] = ssh_options[:password]
rescue Net::SSH::AuthenticationFailed=>ex
# Only if running with console, prompt for password.
if !ssh_options[:password] && $stdout.isatty
password = HighLine.new.ask("Password for #{host}:") { |q| q.echo = "*" }
ssh_options[:password] = password
retry
end
raise
end
session.sftp.connect do |sftp|
puts "connected" if Rake.application.options.trace
# To create a path, we need to create all its parent. We use realpath to determine if
# the path already exists, otherwise mkdir fails.
puts "Creating path #{@base_path}" if Rake.application.options.trace
path.split("/").inject("") do |base, part|
combined = base + part
sftp.realpath combined rescue sftp.mkdir combined, {}
"#{combined}/"
end
with_progress_bar options[:progress] && options[:size], path.split("/"), options[:size] || 0 do |progress|
puts "Uploading to #{path}" if Rake.application.options.trace
sftp.open_handle(path, "w") do |handle|
# Writing in chunks gives us the benefit of a progress bar,
# but also require that we maintain a position in the file,
# since write() with two arguments always writes at position 0.
pos = 0
while chunk = yield(32 * 4096)
sftp.write(handle, chunk, pos)
pos += chunk.size
progress << chunk
end
sftp.setstat(path, :permissions => options[:permissions]) if options[:permissions]
end
end
end
end
end
# File URL. Keep in mind that file URLs take the form of file://host/path
, although the host
# is not used, so typically all you will see are three backslashes. This methods accept common variants,
# like file:/path
but always returns a valid URL.
class FILE < Generic
COMPONENT = [ :host, :path ].freeze
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:///." unless host.blank?
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
# The URL path always starts with a backslash. On most operating systems (Linux, Darwin, BSD) it points
# to the absolute path on the file system. But on Windows, it comes before the drive letter, creating an
# unusable path, so real_path fixes that. Ugly but necessary hack.
def real_path() #:nodoc:
RUBY_PLATFORM =~ /win32/ && path =~ /^\/[a-zA-Z]:\// ? path[1..-1] : 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:///." unless host.blank?
temp = nil
Tempfile.open File.basename(path) do |temp|
temp.binmode
with_progress_bar options[:progress] && options[:size], path.split("/"), options[:size] || 0 do |progress|
while chunk = yield(32 * 4096)
temp.write chunk
progress << chunk
end
end
end
real_path.tap do |path|
mkpath File.dirname(path)
File.move temp.path, path
end
end
@@schemes["FILE"] = FILE
end
end