summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPatrick J Cherry <patrick@bytemark.co.uk>2015-12-01 22:41:06 +0000
committerPatrick J Cherry <patrick@bytemark.co.uk>2015-12-01 22:41:06 +0000
commit668b9871e64cb82ac30c8defb29d56d774f3c140 (patch)
tree9b4bba7cbcbd2660485cf803402c4d4889e62b10
parent182a03798d49a3c0450b0f137977037cf9376e99 (diff)
Completely re-vamped restore command. Fixes #12403
The byteback-restore command now uses the rsync xattrs to display information about the files due to be restored. It can handle filenames with spaces (!) and other characters.
-rwxr-xr-xbin/byteback-receive57
-rwxr-xr-xbin/byteback-restore193
-rw-r--r--debian/changelog7
-rw-r--r--lib/byteback/restore.rb122
-rw-r--r--lib/byteback/restore_file.rb215
-rw-r--r--lib/ffi-xattr.rb78
-rw-r--r--lib/ffi-xattr/darwin_lib.rb47
-rw-r--r--lib/ffi-xattr/error.rb23
-rw-r--r--lib/ffi-xattr/extensions.rb3
-rw-r--r--lib/ffi-xattr/extensions/file.rb9
-rw-r--r--lib/ffi-xattr/extensions/pathname.rb9
-rw-r--r--lib/ffi-xattr/linux_lib.rb52
-rw-r--r--lib/ffi-xattr/version.rb3
-rw-r--r--lib/ffi-xattr/windows_lib.rb45
-rw-r--r--test/tc_restore.rb36
-rw-r--r--test/tc_restore_file.rb22
16 files changed, 840 insertions, 81 deletions
diff --git a/bin/byteback-receive b/bin/byteback-receive
index 5d0d35a..b415f0e 100755
--- a/bin/byteback-receive
+++ b/bin/byteback-receive
@@ -1,4 +1,5 @@
#!/usr/bin/ruby
+# encoding: UTF-8
#
# Program to receive backups and run rsync in receive mode. Must check that
# user as authorised by SSH is allowed to access particular directory.
@@ -7,6 +8,8 @@ $LOAD_PATH << '/usr/lib/byteback'
require 'trollop'
require 'byteback'
+require 'byteback/restore'
+
include Byteback::Log
if ENV['SSH_ORIGINAL_COMMAND']
@@ -22,28 +25,51 @@ fatal("#{byteback_root} does not exist") unless File.directory?(byteback_root)
#
# Force restores to be limited to the hostname we're connecting form
#
-if (ARGV[0] == 'restore')
- ARGV[0] = 'rsync'
- a = []
- ARGV.each do |tmp|
- if tmp =~ /^\/(.*)/
- tmp = "#{byteback_host}/#{Regexp.last_match(1).dup}"
+if (ARGV[0] == 'byteback-restore')
+ args = ["rsync"]
+ revision = nil
+
+ while((arg = ARGV.shift) != ".")
+ break if arg.nil?
+
+ verbose = arg if arg == "--verbose"
+
+ if arg == "--revision"
+ revision = ARGV.shift
+ else
+ args << arg
end
- a.push(tmp)
end
- exec(*a)
+
+ restore = Byteback::Restore.new(byteback_root)
+ restore.revision = revision if revision
+ restore.find(Byteback::Restore.decode_args(ARGV))
+
+ Dir.chdir(byteback_host)
+
+ restore.results.each do |r|
+ args << File.join(".", r.snapshot, r.path)
+ end
+
+ info(args.join(" "))
+ exec(*args)
+
elsif ARGV[0] == 'rsync'
ARGV[-1] = "#{byteback_root}/current"
exec(*ARGV)
+
elsif ARGV[0] == 'byteback-snapshot'
ARGV.concat(['--root', "#{byteback_root}"])
exec(*ARGV)
+
end
opts = Trollop.options do
opt :verbose, 'Print diagnostics'
opt :ping, 'Check connection parameters and exit'
- opt :list, 'Show backed up files matching the given pattern', :type => :string
+ opt :list, 'Show backed up files matching the given pattern'
+ opt :list_all, 'Show all stored versions of a file'
+ opt :revision, 'Show backed up files in a certain revision.', :default => '*'
opt :restore, 'Perform a restoration operation', :type => :string
opt :complete, 'Mark current backup as complete'
end
@@ -52,7 +78,18 @@ error('Please only choose one mode') if opts[:ping] && opts[:complete]
if opts[:complete]
system('byteback-snapshot', '--root', byteback_root)
elsif opts[:list]
- system("cd #{byteback_root} && find . -print | grep #{opts[:list]}")
+ args = Byteback::Restore.decode_args(ARGV[1..-1])
+
+ restore = Byteback::Restore.new(byteback_root)
+ restore.revision = opts[:revision]
+ restore.find(args, :all => opts[:list_all], :verbose => opts[:verbose])
+
+ if restore.results.empty?
+ puts "** Sorry. There were no files matching:"
+ puts "--> "+args.join("\n--> ")
+ else
+ puts restore.list
+ end
exit(0)
elsif opts[:ping]
exit 0
diff --git a/bin/byteback-restore b/bin/byteback-restore
index 7ac47dd..0a068a8 100755
--- a/bin/byteback-restore
+++ b/bin/byteback-restore
@@ -1,19 +1,19 @@
#!/usr/bin/ruby
+# enocoding: UTF-8
#
# Restore a file from the most recent backup, from the remote host.
#
$LOAD_PATH.unshift('/usr/lib/byteback')
-$LOAD_PATH.unshift('./lib/')
+$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '../lib')) # For development
require 'trollop'
require 'byteback/util'
require 'byteback/log'
+require 'byteback/restore'
include Byteback::Util
include Byteback::Log
-
-
#
# Run a remote command.
#
@@ -31,11 +31,16 @@ def ssh(*ssh_args)
] +
ssh_args.map { |a| a ? a : '' }
+ puts args.join(" " ) if @verbose
system(*args)
end
-def list_files(pattern)
- ssh('byteback-receive', '--list', pattern)
+def list_files(revision, list_all, pattern)
+ args = ['byteback-receive', '--revision', revision, '--list']
+ args << "--list-all" if list_all
+ args << @verbose if @verbose
+ args += Byteback::Restore.encode_args(pattern)
+ ssh(*args)
end
#
@@ -46,77 +51,123 @@ end
# do that by setting "rsync-path" to point to a faux script.
#
#
-def restore_file(path, revision)
- cmd = %w( rsync )
- cmd += ['--rsh', 'ssh -o BatchMode=yes -x -a -i /etc/byteback/key -l byteback']
- cmd += ['--rsync-path', 'restore --fake-super']
- cmd += ['-aApzrX', '--numeric-ids']
- cmd += ["#{@destination_host}:/#{revision}/#{path}", '.']
- system(*cmd)
-end
-
-#
-# Parse our command-line arguments
-#
-opts = Trollop.options do
- banner "byteback-restore: Restore a file\n "
-
- opt :file, 'The file to restore/list.',
- :type => :string
+def restore_files(paths, revision)
- opt :revision, "The version of the file to restore.",
- :type => :string
-
- opt :destination, 'Backup destination (i.e. user@host:/path).',
- :type => :string
-
- opt :ssh_key, 'SSH key filename',
- :type => :string,
- :default => '/etc/byteback/key',
- :short => 'k'
-end
-
-#
-# Setup default destination and key.
-#
-@destination = File.read('/etc/byteback/destination').chomp if
- File.exist?('/etc/byteback/destination')
-@ssh_key = '/etc/byteback/key' if File.exist?('/etc/byteback/key')
+ #
+ # Basic args
+ #
+ args = %w(rsync --archive --acls --numeric-ids --inplace --relative --xattrs --compress --no-implied-dirs)
-#
-# Allow the command-line to override them.
-#
-@ssh_key = opts[:ssh_key] unless opts[:ssh_key].nil?
-@destination = opts[:destination] unless opts[:destination].nil?
+ #
+ # Add on the I/O-timeout
+ #
+ args += ['--timeout', @io_timeout.to_s ] unless ( @io_timeout.nil? )
+ args += ['--rsh', "ssh -o BatchMode=yes -x -a -i #{@ssh_key} -l #{@destination_user}"]
+ args << '--verbose' if @verbose
+ args += ['--rsync-path', "byteback-restore --fake-super --revision #{revision}"]
+ dst = "#{@destination_user}@#{@destination_host}:"
-#
-# Check our destination is well-formed
-#
-if @destination =~ /^(?:(.+)@)?([^@:]+):(.+)?$/
- @destination_user, @destination_host, @destination_path = [Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3)]
-else
- fatal('Destination must be a remote path, e.g. ssh@host.com:/store/backups')
-end
+ paths.each do |path|
+ path = Byteback::Restore.encode_args(path).first
+ args << File.join(dst,path)
+ dst = ":"
+ end
-#
-# If the user didn't specify a file then we're not restoring anything,
-# and we should abort.
-#
-if opts[:file].nil?
- fatal('You must specify a file to search/restore')
+ args << "."
+ puts args.join(" " ) if @verbose
+ system(*args)
end
-#
-# If the user specified a file, but not a revision, then we list
-# the available revisions.
-#
-if opts[:revision].nil?
- list_files(opts[:file])
+##
+## Entry-point to our code.
+##
+if __FILE__ == $PROGRAM_NAME
+
+ ME = File.basename($PROGRAM_NAME)
+
+ #
+ # Parse our command-line arguments
+ #
+ opts = Trollop.options do
+ banner "#{ME}: Restore a file to this system from a byteback-enabled server\n "
+
+ opt :restore, "Restore the files"
+
+ opt :revision, "The version of the file to restore.",
+ :type => :string, :default => "*"
+
+ opt :destination, 'Backup destination (i.e. user@host:/path).',
+ :type => :string
+
+ opt :verbose, 'Show debugging messages'
+
+ opt :io_timeout, 'Number of seconds to allow I/O timeout for',
+ :type => :integer,
+ :default => 10800
+
+ opt :ssh_key, 'SSH key filename',
+ :type => :string,
+ :default => '/etc/byteback/key',
+ :short => 'k'
+
+ opt :list_all, 'List all versrions of each file'
+
+ end
+
+ @verbose = opts[:verbose] ? '--verbose' : nil
+ @io_timeout = opts[:io_timeout] if opts[:io_timeout]
+
+ # Read the default destination
+ if File.exist?('/etc/byteback/destination')
+ @destination = File.read('/etc/byteback/destination').chomp
+ end
+
+ # Set the default SSH key
+ if File.exist?('/etc/byteback/key')
+ @ssh_key = '/etc/byteback/key'
+ end
+
+ #
+ # Allow the command-line to override them.
+ #
+ @ssh_key = opts[:ssh_key] unless opts[:ssh_key].nil?
+ @destination = opts[:destination] unless opts[:destination].nil?
+
+ #
+ # Check our destination
+ #
+ fatal('Must suply --destination or put it into /etc/bytebackup/destination') unless @destination
+
+ #
+ # Check our destination is well-formed
+ #
+ if @destination =~ /^(?:(.+)@)?([^@:]+):(.+)?$/
+ @destination_user, @destination_host, @destination_path = [Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3)]
+ else
+ fatal('Destination must be a remote path, e.g. ssh@host.com:/store/backups')
+ end
+
+ #
+ # Test that we have an SSH-key which we can read.
+ #
+ fatal("Could not read ssh key #{@ssh_key}") unless File.readable?(@ssh_key)
+
+ #
+ # If the user didn't specify a file then we're not restoring anything,
+ # and we should abort.
+ #
+ if ARGV.empty?
+ fatal('You must specify a file to search/restore')
+ end
+
+ if opts[:restore]
+ #
+ # Restore a file
+ #
+ restore_files(ARGV.collect{|a| File.expand_path(a)}, opts[:revision])
+ exit(0)
+ end
+
+ list_files(opts[:revision], opts[:list_all], ARGV.collect{|a| File.expand_path(a)})
exit(0)
end
-
-#
-# Restore a file
-#
-restore_file(opts[:file], opts[:revision])
-exit(0)
diff --git a/debian/changelog b/debian/changelog
index 662446f..e1d7cd9 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,10 @@
+byteback (0.4.0) stable; urgency=medium
+
+ * Re-vamped restore command. Needs further documentation.
+ * Closes security hole with byteback-restore command (closes #12403).
+
+ -- Patrick J Cherry <patrick@bytemark.co.uk> Tue, 01 Dec 2015 12:35:35 +0000
+
byteback (0.3.10) stable; urgency=medium
* Fixed bug introduced in last release caused by a missing variable.
diff --git a/lib/byteback/restore.rb b/lib/byteback/restore.rb
new file mode 100644
index 0000000..e2df3fe
--- /dev/null
+++ b/lib/byteback/restore.rb
@@ -0,0 +1,122 @@
+
+require 'byteback/restore_file'
+
+module Byteback
+
+ class Restore
+
+ def self.find(byteback_root, revision, paths)
+ x = Byteback::Restore.new(byteback_root)
+ x.revision = revision
+ x.find(paths)
+ return x
+ end
+
+ #
+ # This takes a string or array of strings as an argument, and QP encodes
+ # each argument. This is for safe parsing of spaces etc at the remote end.
+ #
+ # Returns an array of encoded strings.
+ #
+ def self.encode_args(args)
+ [args].flatten.collect{|s| [s].pack("M").gsub(" ","=20").gsub("=\n","")}
+ end
+
+ #
+ # This takes a string or array of strings, each of which is quoted
+ # printable, and unpacks it.
+ #
+ # Returns an array of decoded strings.
+ #
+ def self.decode_args(args)
+ [args].flatten.collect{|s| (s + "=\n").unpack("M")}.flatten
+ end
+
+ def initialize(byteback_root)
+ #
+ # We use expand_path here to make sure we have a full path, with no
+ # trailing slash.
+ #
+ @byteback_root = File.expand_path(byteback_root)
+ @now = Time.now
+ @revision = "*"
+ @results = []
+ end
+
+ def revision=(r)
+ if r =~ /^[a-z0-9:\+\*\-]+$/i
+ @revision = r
+ else
+ puts "*** Warning: Bad revision #{r.inspect}"
+ end
+ end
+
+ def results
+ @results
+ end
+
+ def find(paths, full = false)
+ results = []
+ #
+ # Make sure we've an array, and that we get rid of any ".." nonsense.
+ #
+ paths = [paths].flatten.collect{|p| File.expand_path(p, "/")}
+ seen = []
+
+ @results = paths.collect do |path|
+ Dir.glob(File.expand_path(File.join(@byteback_root, @revision, path))).collect do |f|
+ restore_file = Byteback::RestoreFile.new(f, @byteback_root, @now)
+ end
+ end.flatten.sort{|a,b| [a.path, a.snapshot_time] <=> [b.path, b.snapshot_time]}
+
+ #
+ # If we want an unpruned list, return it now.
+ #
+ return @results if full
+
+ pruned_results = []
+
+ @results.each do |r|
+ pruned_results << r unless pruned_results.include?(r)
+ end
+
+ @results = pruned_results
+ end
+
+ def list
+ heading = %w(snapshot modestring size uid gid mtime path)
+ listings = [heading]
+ @results.sort.each do |r|
+ listing = heading.collect{|m| r.__send__(m.to_sym).to_s }
+ if r.symlink?
+ listing[-1] << " -> "+r.readlink
+ end
+ listings << listing
+ end
+
+ field_sizes = [0]*heading.length
+
+ listings.each do |fields|
+ fields.each_with_index do |field, i|
+ field_sizes[i] = (field_sizes[i] > field.length) ? field_sizes[i] : field.length
+ end
+ end
+
+ fmt = field_sizes.collect{|i| "%-#{i}.#{i}s"}.join(" ")
+
+ bar = "-"*field_sizes.inject(field_sizes.length){|m,s| m+=s}
+
+ output = []
+ listings.each do |fields|
+ output << sprintf(fmt, *fields)
+ if bar
+ output << bar
+ bar = nil
+ end
+ end
+
+ return output.join("\n")
+ end
+
+ end
+end
diff --git a/lib/byteback/restore_file.rb b/lib/byteback/restore_file.rb
new file mode 100644
index 0000000..e9282d0
--- /dev/null
+++ b/lib/byteback/restore_file.rb
@@ -0,0 +1,215 @@
+require 'ffi-xattr'
+require 'scanf'
+require 'pp'
+
+module Byteback
+ class RestoreFile
+ S_IFMT = 0170000 # bit mask for the file type bit fields
+
+ S_IFSOCK = 0140000 # socket
+ S_IFLNK = 0120000 # symbolic link
+ S_IFREG = 0100000 # regular file
+ S_IFBLK = 0060000 # block device
+ S_IFDIR = 0040000 # directory
+ S_IFCHR = 0020000 # character device
+ S_IFIFO = 0010000 # FIFO
+
+ S_ISUID = 0004000 # set-user-ID bit
+ S_ISGID = 0002000 # set-group-ID bit (see below)
+ S_ISVTX = 0001000 # sticky bit (see below)
+
+ S_IRWXU = 00700 # mask for file owner permissions
+ S_IRUSR = 00400 # owner has read permission
+ S_IWUSR = 00200 # owner has write permission
+ S_IXUSR = 00100 # owner has execute permission
+
+ S_IRWXG = 00070 # mask for group permissions
+ S_IRGRP = 00040 # group has read permission
+ S_IWGRP = 00020 # group has write permission
+ S_IXGRP = 00010 # group has execute permission
+
+ S_IRWXO = 00007 # mask for permissions for others (not in group)
+ S_IROTH = 00004 # others have read permission
+ S_IWOTH = 00002 # others have write permission
+ S_IXOTH = 00001 # others have execute permission
+
+ include Comparable
+
+ def initialize(full_path, byteback_root=".", now = Time.now)
+ @full_path = full_path
+ @byteback_root = byteback_root
+ @now = now
+
+ #
+ # The snapshot is the first directory after the byteback_root
+ #
+ @snapshot = full_path.sub(%r(^#{Regexp.escape @byteback_root}),'').split("/")[1]
+
+ if @snapshot == "current"
+ @snapshot_time = @now
+ else
+ @snapshot_time = Time.parse(@snapshot)
+ end
+
+ #
+ # Restore path
+ #
+ @path = full_path.sub(%r(^#{Regexp.escape @byteback_root}/#{Regexp.escape @snapshot}),'')
+
+ @stat = @mode = @dev_major = @dev_minor = @uid = @gid = nil
+ end
+
+ def <=>(other)
+ [self.path, self.mtime.to_i, self.size] <=> [other.path, other.mtime.to_i, other.size]
+ end
+
+ def stat
+ @stat = ::File.lstat(@full_path) unless @stat.is_a?(File::Stat)
+ @stat
+ end
+
+ def snapshot
+ @snapshot
+ end
+
+ def snapshot_time
+ @snapshot_time
+ end
+
+ def path
+ @path
+ end
+
+ def to_s
+ sprintf("%10s %i %4i %4i %s %s %s", self.modestring, self.size, self.uid, self.gid, self.mtime.strftime("%b %2d %H:%M"), @snapshot, @path)
+ end
+
+ def read_rsync_xattrs
+ xattr = Xattr.new(@full_path, :no_follow => false)
+ rsync_xattrs = xattr["user.rsync.%stat"]
+ if rsync_xattrs
+ @mode, @dev_major, @dev_minor, @uid, @gid = rsync_xattrs.scanf("%o %d,%d %d:%d")
+ raise ArgumentError, "Corrupt rsync stat xattr found for #{@full_path} (#{rsync_xattrs})" unless [@mode, @dev_major, @dev_minor, @uid, @gid].all?{|i| i.is_a?(Integer)}
+ else
+ warn "No rsync stat xattr found for #{@full_path}"
+ @mode, @dev_major, @dev_minor, @uid, @gid = %w(mode dev_major dev_minor uid gid).collect{|m| self.stat.__send__(m.to_sym)}
+ end
+ end
+
+ def mode
+ return self.stat.mode if self.stat.symlink?
+ read_rsync_xattrs unless @mode
+ @mode
+ end
+
+ def dev_minor
+ read_rsync_xattrs unless @dev_minor
+ @dev_minor
+ end
+
+ def dev_major
+ read_rsync_xattrs unless @dev_major
+ @dev_major
+ end
+
+ def uid
+ read_rsync_xattrs unless @uid
+ @uid
+ end
+
+ def gid
+ read_rsync_xattrs unless @gid
+ @gid
+ end
+
+ #
+ # This returns the type of file as a single character.
+ #
+ def ftypelet
+ if file?
+ "-"
+ elsif directory?
+ "d"
+ elsif blockdev?
+ "b"
+ elsif chardev?
+ "c"
+ elsif symlink?
+ "l"
+ elsif fifo?
+ "p"
+ elsif socket?
+ "s"
+ else
+ "?"
+ end
+ end
+
+ #
+ # This returns a modestring from the octal, like drwxr-xr-x.
+ # This has mostly been copied from strmode from filemode.h in coreutils.
+ #
+ def modestring
+ str = ""
+ str << ftypelet
+ str << ((mode & S_IRUSR == S_IRUSR) ? 'r' : '-')
+ str << ((mode & S_IWUSR == S_IWUSR) ? 'w' : '-')
+ str << ((mode & S_ISUID == S_ISUID) ?
+ ((mode & S_IXUSR == S_IXUSR) ? 's' : 'S') :
+ ((mode & S_IXUSR == S_IXUSR) ? 'x' : '-'))
+ str << ((mode & S_IRGRP == S_IRGRP) ? 'r' : '-')
+ str << ((mode & S_IWGRP == S_IWGRP) ? 'w' : '-')
+ str << ((mode & S_ISGID == S_ISGID) ?
+ ((mode & S_IXGRP == S_IXGRP) ? 's' : 'S') :
+ ((mode & S_IXGRP == S_IXGRP) ? 'x' : '-'))
+ str << ((mode & S_IROTH == S_IROTH) ? 'r' : '-')
+ str << ((mode & S_IWOTH == S_IWOTH) ? 'w' : '-')
+ str << ((mode & S_ISVTX == S_ISVTX) ?
+ ((mode & S_IXOTH == S_IXOTH) ? 't' : 'T') :
+ ((mode & S_IXOTH == S_IXOTH) ? 'x' : '-'))
+ return str
+ end
+
+ def socket?
+ (mode & S_IFMT) == S_IFSOCK
+ end
+
+ def symlink?
+ self.stat.symlink? || (mode & S_IFMT) == S_IFLNK
+ end
+
+ def file?
+ (mode & S_IFMT) == S_IFREG
+ end
+
+ def blockdev?
+ (mode & S_IFMT) == S_IFBLK
+ end
+
+ def directory?
+ (mode & S_IFMT) == S_IFDIR
+ end
+
+ def chardev?
+ (mode & S_IFMT) == S_IFCHR
+ end
+
+ def fifo?
+ (mode & S_IFMT) == S_IFIFO
+ end
+
+ def readlink
+ if self.stat.symlink?
+ File.readlink(@full_path)
+ else
+ File.read(@full_path).chomp
+ end
+ end
+
+ def method_missing(m, *args, &blk)
+ return self.stat.__send__(m) if self.stat.respond_to?(m)
+
+ raise NoMethodError, m
+ end
+ end
+end
diff --git a/lib/ffi-xattr.rb b/lib/ffi-xattr.rb
new file mode 100644
index 0000000..2dd1e23
--- /dev/null
+++ b/lib/ffi-xattr.rb
@@ -0,0 +1,78 @@
+require 'ffi'
+require 'ffi-xattr/version'
+require 'ffi-xattr/error'
+
+case RUBY_PLATFORM
+when /linux/
+ require 'ffi-xattr/linux_lib'
+when /darwin|bsd/
+ require 'ffi-xattr/darwin_lib'
+when /mingw/
+ require 'ffi-xattr/windows_lib'
+else
+ raise NotImplementedError, "ffi-xattr not supported on #{RUBY_PLATFORM}"
+end
+
+class Xattr
+ include Enumerable
+
+ # Create a new Xattr instance with path.
+ # Use <tt>:no_follow => true</tt> in options to work on symlink itself instead of following it.
+ def initialize(path, options = {})
+ @path =
+ if path.respond_to?(:to_path)
+ path.to_path
+ elsif path.respond_to?(:to_str)
+ path.to_str
+ else
+ path
+ end
+ raise Errno::ENOENT, @path unless File.exist?(@path)
+
+ @no_follow = !!options[:no_follow]
+ end
+
+ # List extended attribute names
+ def list
+ Lib.list @path, @no_follow
+ end
+
+ # Get an extended attribute value
+ def get(key)
+ Lib.get @path, @no_follow, key.to_s
+ end
+ alias_method :[], :get
+
+ # Set an extended attribute value
+ def set(key, value)
+ Lib.set @path, @no_follow, key.to_s, value.to_s
+ end
+ alias_method :[]=, :set
+
+ # Remove an extended attribute value
+ def remove(key)
+ Lib.remove @path, @no_follow, key.to_s
+ end
+
+ # Iterates over pairs of extended attribute names and values
+ def each(&blk)
+ list.each do |key|
+ yield key, get(key)
+ end
+ end
+
+ # Returns hash of extended attributes
+
+ def to_hash
+ res = {}
+ each { |k,v| res[k] = v }
+
+ res
+ end
+
+ alias_method :to_h, :to_hash
+
+ def as_json(*args)
+ to_hash
+ end
+end
diff --git a/lib/ffi-xattr/darwin_lib.rb b/lib/ffi-xattr/darwin_lib.rb
new file mode 100644
index 0000000..59f0810
--- /dev/null
+++ b/lib/ffi-xattr/darwin_lib.rb
@@ -0,0 +1,47 @@
+class Xattr # :nodoc: all
+ module Lib
+ extend FFI::Library
+
+ ffi_lib "System"
+
+ attach_function :listxattr, [:string, :pointer, :size_t, :int], :ssize_t
+ attach_function :getxattr, [:string, :string, :pointer, :size_t, :uint, :int], :ssize_t
+ attach_function :setxattr, [:string, :string, :pointer, :size_t, :uint, :int], :int
+ attach_function :removexattr, [:string, :string, :int], :int
+
+ XATTR_NOFOLLOW = 0x0001
+
+ class << self
+ def list(path, no_follow)
+ options = no_follow ? XATTR_NOFOLLOW : 0
+ size = listxattr(path, nil, 0, options)
+ res_ptr = FFI::MemoryPointer.new(:pointer, size)
+ listxattr(path, res_ptr, size, options)
+
+ res_ptr.read_string(size).split("\000")
+ end
+
+ def get(path, no_follow, key)
+ options = no_follow ? XATTR_NOFOLLOW : 0
+ size = getxattr(path, key, nil, 0, 0, options)
+ return unless size > 0
+
+ str_ptr = FFI::MemoryPointer.new(:char, size)
+ getxattr(path, key, str_ptr, size, 0, options)
+
+ str_ptr.read_string(size)
+ end
+
+ def set(path, no_follow, key, value)
+ options = no_follow ? XATTR_NOFOLLOW : 0
+ Error.check setxattr(path, key, value, value.bytesize, 0, options)
+ end
+
+ def remove(path, no_follow, key)
+ options = no_follow ? XATTR_NOFOLLOW : 0
+ Error.check removexattr(path, key, options)
+ end
+ end
+
+ end
+end
diff --git a/lib/ffi-xattr/error.rb b/lib/ffi-xattr/error.rb
new file mode 100644
index 0000000..602053d
--- /dev/null
+++ b/lib/ffi-xattr/error.rb
@@ -0,0 +1,23 @@
+class Xattr # :nodoc: all
+ module Error
+ extend FFI::Library
+
+ ffi_lib "c"
+
+ attach_function :strerror_r, [:int, :pointer, :size_t], :int unless RUBY_PLATFORM =~ /mingw/
+
+ class << self
+ def last
+ errno = FFI.errno
+ ptr = FFI::MemoryPointer.new(:char, 256)
+ strerror_r(errno, ptr, 256)
+
+ [ ptr.read_string, errno ]
+ end
+
+ def check(int)
+ raise SystemCallError.new(*last) if int != 0
+ end
+ end
+ end
+end
diff --git a/lib/ffi-xattr/extensions.rb b/lib/ffi-xattr/extensions.rb
new file mode 100644
index 0000000..4ecad8a
--- /dev/null
+++ b/lib/ffi-xattr/extensions.rb
@@ -0,0 +1,3 @@
+require 'ffi-xattr/extensions/file'
+require 'ffi-xattr/extensions/pathname'
+
diff --git a/lib/ffi-xattr/extensions/file.rb b/lib/ffi-xattr/extensions/file.rb
new file mode 100644
index 0000000..200f024
--- /dev/null
+++ b/lib/ffi-xattr/extensions/file.rb
@@ -0,0 +1,9 @@
+require 'ffi-xattr'
+
+class File
+
+ # Returns an Xattr object for the named file (see Xattr).
+ def self.xattr(file_name)
+ Xattr.new(file_name)
+ end
+end
diff --git a/lib/ffi-xattr/extensions/pathname.rb b/lib/ffi-xattr/extensions/pathname.rb
new file mode 100644
index 0000000..c2ed11e
--- /dev/null
+++ b/lib/ffi-xattr/extensions/pathname.rb
@@ -0,0 +1,9 @@
+require 'ffi-xattr/extensions/file'
+
+class Pathname
+ # Returns an Xattr object.
+ # See File.xattr.
+ def xattr
+ File.xattr(self)
+ end
+end
diff --git a/lib/ffi-xattr/linux_lib.rb b/lib/ffi-xattr/linux_lib.rb
new file mode 100644
index 0000000..e90a8ad
--- /dev/null
+++ b/lib/ffi-xattr/linux_lib.rb
@@ -0,0 +1,52 @@
+class Xattr # :nodoc: all
+ module Lib
+ extend FFI::Library
+
+ ffi_lib "c"
+
+ attach_function :strerror, [:int], :string
+
+ attach_function :listxattr, [:string, :pointer, :size_t], :size_t
+ attach_function :setxattr, [:string, :string, :pointer, :size_t, :int], :int
+ attach_function :getxattr, [:string, :string, :pointer, :size_t], :int
+ attach_function :removexattr, [:string, :string], :int
+
+ attach_function :llistxattr, [:string, :pointer, :size_t], :size_t
+ attach_function :lsetxattr, [:string, :string, :pointer, :size_t, :int], :int
+ attach_function :lgetxattr, [:string, :string, :pointer, :size_t], :int
+ attach_function :lremovexattr, [:string, :string], :int
+
+ class << self
+ def list(path, no_follow)
+ method = no_follow ? :llistxattr : :listxattr
+ size = send(method, path, nil, 0)
+ res_ptr = FFI::MemoryPointer.new(:pointer, size)
+ send(method, path, res_ptr, size)
+
+ res_ptr.read_string(size).split("\000")
+ end
+
+ def get(path, no_follow, key)
+ method = no_follow ? :lgetxattr : :getxattr
+ size = send(method, path, key, nil, 0)
+ return unless size > 0
+
+ str_ptr = FFI::MemoryPointer.new(:char, size)
+ send(method, path, key, str_ptr, size)
+
+ str_ptr.read_string(size)
+ end
+
+ def set(path, no_follow, key, value)
+ method = no_follow ? :lsetxattr : :setxattr
+ Error.check send(method, path, key, value, value.bytesize, 0)
+ end
+
+ def remove(path, no_follow, key)
+ method = no_follow ? :lremovexattr : :removexattr
+ Error.check send(method, path, key)
+ end
+ end
+
+ end
+end
diff --git a/lib/ffi-xattr/version.rb b/lib/ffi-xattr/version.rb
new file mode 100644
index 0000000..eaafe38
--- /dev/null
+++ b/lib/ffi-xattr/version.rb
@@ -0,0 +1,3 @@
+class Xattr
+ VERSION = "0.1.2"
+end
diff --git a/lib/ffi-xattr/windows_lib.rb b/lib/ffi-xattr/windows_lib.rb
new file mode 100644
index 0000000..d4199cc
--- /dev/null
+++ b/lib/ffi-xattr/windows_lib.rb
@@ -0,0 +1,45 @@
+# encoding: utf-8
+require 'ffi-xattr'
+
+class Xattr # :nodoc: all
+ module Lib
+ class << self
+
+ def list(path, no_follow)
+ lines = `dir /r "#{path}"`.split("\n")
+
+ xattrs = []
+ lines.each { |line|
+ if line =~ /\:\$DATA$/
+ size = line.split(' ')[0].gsub(/[^0-9]/,'').to_i
+
+ if size > 0
+ xattrs << line.split(':')[1]
+ end
+ end
+ }
+ xattrs
+ end
+
+ def get(path, no_follow, key)
+ fp = "#{path}:#{key}"
+ if File.exists?(fp)
+ File.binread(fp)
+ else
+ raise "No such key. #{key.inspect} #{path.inspect}"
+ end
+ end
+
+ def set(path, no_follow, key, value)
+ File.open("#{path}:#{key}",'wb') { |io| io << value }
+ end
+
+ def remove(path, no_follow, key)
+ # done this way because Windows have no function to remove Alternate Data Stream
+ # quickest way is to set the value to 0 byte length instead of trying to create another file then apply the attributes, especially when dealing with a big file
+ self.set(path, false, key, '')
+ end
+ end
+
+ end
+end
diff --git a/test/tc_restore.rb b/test/tc_restore.rb
new file mode 100644
index 0000000..f1e8b04
--- /dev/null
+++ b/test/tc_restore.rb
@@ -0,0 +1,36 @@
+$: << File.dirname(__FILE__)+"/../lib"
+
+require 'test/unit'
+require 'byteback/restore'
+require 'tmpdir'
+require 'time'
+require 'fileutils'
+
+class RestoreTest < Test::Unit::TestCase
+
+ def setup
+ @byteback_root = Dir.mktmpdir
+ @snapshot = Time.now.iso8601
+ FileUtils.mkdir_p(File.join(@byteback_root, @snapshot))
+ end
+
+ def teardown
+ FileUtils.remove_entry_secure @byteback_root
+ end
+
+ def test_find
+ files = %w(/srv/foo.com/public/htdocs/index.html
+ /srv/foo.com/public/htdocs/app.php)
+
+ files.each do |f|
+ FileUtils.mkdir_p(File.join(@byteback_root, @snapshot, File.dirname(f)))
+ FileUtils.touch(File.join(@byteback_root, @snapshot, f))
+ system("setfattr --name user.rsync.%stat -v \"41755 12,34 56:78\" #{File.join(@byteback_root, @snapshot, f)}")
+ end
+
+ r = Byteback::Restore.find(@byteback_root, "/srv/foo.com/public/htdocs/*.html")
+
+ r.list
+ end
+
+end
diff --git a/test/tc_restore_file.rb b/test/tc_restore_file.rb
new file mode 100644
index 0000000..b0497b3
--- /dev/null
+++ b/test/tc_restore_file.rb
@@ -0,0 +1,22 @@
+$: << File.dirname(__FILE__)+"/../lib"
+
+require 'test/unit'
+require 'byteback/restore_file'
+require 'tempfile'
+
+class BytebackFileTest < Test::Unit::TestCase
+
+ def test_general
+ f = Tempfile.new($0)
+ system("setfattr --name user.rsync.%stat -v \"41755 12,34 56:78\" #{f.path}")
+ b = Byteback::RestoreFile.new(f.path)
+ assert_equal(041755, b.mode)
+ assert_equal(12, b.maj)
+ assert_equal(34, b.min)
+ assert_equal(56, b.uid)
+ assert_equal(78, b.gid)
+ assert_equal("drwxr-xr-t", b.modestring)
+ assert_kind_of(Time, f.mtime)
+ end
+
+end