Skip to content
Find file
Fetching contributors…
Cannot retrieve contributors at this time
executable file 663 lines (544 sloc) 15.9 KB
#! /usr/bin/env ruby2.3
#
# Copyright (C) 2014 Michael Schürig <michael@schuerig.de>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
require 'digest/sha2'
require 'gpgme'
class BottomUpDirectories
include Enumerable
def initialize(*dirs)
options = dirs.last.is_a?(Hash) ? dirs.pop : {}
@dirs = dirs
@excludes = Array(options[:exclude])
end
def each(&block)
@dirs.each do |d|
root_re = %r{^#{d}/}
each_dir([d], root_re, &block)
end
end
private
MATCH_FLAGS = File::FNM_PATHNAME
def each_dir(dirs, root_re, &block)
dirs.each do |dir|
begin
children = []
Dir.open(dir) do |d|
d.each do |it|
next if it == '.' || it == '..'
path = File.join(dir, it)
next unless File.exist?(path) && File.lstat(path).directory?
unless @excludes.empty?
match_path = path.sub(root_re, '')
next if @excludes.any? { |excl| File.fnmatch(excl, match_path, MATCH_FLAGS) }
end
children << path
end
end
each_dir(children, root_re, &block)
block[dir]
rescue Errno::ENOENT, Errno::EACCES
### really?
end
end
end
end
module Checksums
VERSION = '0.2'
CHECKSUM_FILENAME = '.checksums'
DIGEST = Digest::SHA256
CALLBACKS = [
[ :valid_signature, 3 ],
[ :invalid_signature, 3 ],
[ :directory_unchanged, 1 ],
[ :directory_changed, 1 ],
[ :item_unchanged, 3 ],
[ :item_changed, 4 ],
[ :item_added, 2 ],
[ :item_removed, 2 ]
].freeze
class Checksums
attr_reader :directory, :checksums
def self.for_files(dir, paths)
self.new(dir, checksums_from_paths(paths))
end
def self.from_formatted_checksums(dir, data)
self.new(dir, parse_formatted_checksums(data))
end
def self.for_unchecked_directory(dir)
self.new(dir, [])
end
def for_file(file)
checksums.assoc(file)[1]
end
alias :[] :for_file
def formatted
checksums.map { |path, hash|
"#{hash} #{path}"
}.join("\n") + "\n"
end
def ==(other)
checksums == other.checksums
end
def changes(other)
Changes.new.tap { |ch|
report_changes(other, ch)
}.immutable
end
def report_changes(other, callbacks)
compare_items = catch(:skip_item_comparison) do
if @checksums == other.checksums
callbacks.notify_directory_unchanged(directory)
false
else
callbacks.notify_directory_changed(directory)
true
end
end
if compare_items
expecteds = @checksums.dup
actuals = other.checksums.dup
e_file, e_hash = expecteds.shift
a_file, a_hash = actuals.shift
until e_file.nil? && a_file.nil? do
case
when e_file == a_file
if e_hash == a_hash
callbacks.notify_item_unchanged(directory, e_file, e_hash)
else
callbacks.notify_item_changed(directory, e_file, e_hash, a_hash)
end
e_file, e_hash = expecteds.shift
a_file, a_hash = actuals.shift
when a_file.nil? || e_file && (e_file < a_file)
callbacks.notify_item_removed(directory, e_file)
e_file, e_hash = expecteds.shift
when e_file.nil? || a_file && (a_file < e_file)
callbacks.notify_item_added(directory, a_file)
a_file, a_hash = actuals.shift
else
raise 'There is a fault in the comparison algorithm.'
end
end
end
end
def inspect
checksums.inspect
end
private
def initialize(dir, checksums)
@directory, @checksums = dir, checksums
end
def self.checksums_from_paths(paths)
paths.inject([]) { |sums, path|
stat = File.lstat(path)
hash = '-'
case
when stat.symlink?
hash = DIGEST.hexdigest(File.readlink(path))
when stat.directory?
# An empty or unchecked directory does not have a checksum file and
# gets an empty checksum.
# TODO calculate digest for unwrapped (unsigned) checksum file?
hash = DIGEST.file(File.join(path, CHECKSUM_FILENAME)).hexdigest rescue ''
when stat.file?
hash = DIGEST.file(path).hexdigest
end
sums << [ File.basename(path), hash ]
sums
}.sort!
end
def self.parse_formatted_checksums(data)
data.lines.map { |l|
hash, file = l.chomp.split(' ', 2)
[ file, hash ]
}.sort!
end
end
class CheckedRootDirs
include Enumerable
def initialize(roots, recursive, excludes = nil, signer = nil)
@roots = roots.map { |root| File.expand_path(root) }
@recursive = recursive
@excludes = excludes
@signer = signer || Signer.new
end
def each
checked_paths.each do |path|
yield CheckedDir.new(path, @signer)
end
end
private
def checked_paths
if @recursive
BottomUpDirectories.new(*(@roots + [{ :exclude => @excludes }]))
else
@roots
end
end
end
class CheckedDir
attr_accessor :checksum_file, :path
def initialize(dir, signer = nil)
@dir = dir
@signer = signer || Signer.new
@checksum_file = File.join(@dir, CHECKSUM_FILENAME).freeze
end
def path
@dir
end
def ignored?
empty? && !File.file?(File.join(@dir, CHECKSUM_FILENAME))
end
def needs_update?
!checked? || !up_to_date?
end
def write_checksum_file
write_file(@checksum_file, signed_checksums)
# Touch this directory so that the parent directory sees
# that its checksums need to be updated.
touch_file(@dir)
end
def verify_checksums(&block)
if block_given?
callbacks = VerificationCallbacks.new(&block)
else
callbacks = Changes.new(@dir)
end
catch(:skip_checksum_comparison) do
expected = read_checksums(callbacks)
expected.report_changes(checksums, callbacks)
end
block_given? ? nil : callbacks
end
def delete_checksum_file
File.delete(@checksum_file) if File.exist?(@checksum_file)
end
def saved_checksums
read_checksums
end
def to_s
@dir
end
private
def empty?
entries.empty?
end
def checked?
File.exist?(@checksum_file)
end
def up_to_date?
checksum_time = File.mtime(@checksum_file)
# This check depends on the (sub-)directories being touched
# when their checksum files are written.
entries.all? { |other| File.lstat(other).mtime <= checksum_time }
end
def entries
@entries ||=
Dir.entries(@dir).reject { |f| f == '.' || f == '..' || f == CHECKSUM_FILENAME }.map { |f|
File.join(@dir, f)
}
end
def checksums
Checksums.for_files(@dir, entries)
end
def signed_checksums
GPGME::Crypto.clearsign(checksums.formatted, :signers => @signer.signers).read
end
def read_checksums(callbacks = VerificationCallbacks.new)
return Checksums.for_unchecked_directory(@dir) unless File.file?(@checksum_file)
signed_sums = File.read(@checksum_file)
verified_checksums = verify_signature(signed_sums)
if verified_checksums[:valid]
callbacks.notify_valid_signature(@dir, @signer, verified_checksums[:message])
else
callbacks.notify_invalid_signature(@dir, @signer, verified_checksums[:message])
end
Checksums.from_formatted_checksums(@dir, verified_checksums[:checksums].force_encoding('UTF-8'))
end
def verify_signature(sums)
{}.tap do |result|
result[:checksums] = GPGME::Crypto.verify(sums) do |signature|
result[:valid] = @signer.valid_signature?(signature)
result[:message] = signature.to_s
end.read
end
end
def write_file(path, data)
File.open(path, "w") do |f|
f.chown(nil, Process.egid) # override possible SGID on directory
f.chmod(0644) # rw-r--r--
f.write(data)
end
end
def touch_file(path)
now = Time.now
File.utime(now, now, path)
end
end
class Signer
def initialize(signer = nil)
@signer = signer
end
def signers
@signer ? [signer_key] : nil
end
def valid_signature?(signature)
(GPGME::gpgme_err_code(signature.status) == GPGME::GPG_ERR_NO_ERROR) &&
acceptable_signature?(signature)
end
def acceptable_signature?(signature)
acceptable_keyids.include?(signature.fingerprint[-16..-1])
end
def to_s
keys = acceptable_keys.map { |key|
"#{key.subkeys[0].keyid} #{key.uids[0].uid}"
}
(keys.length > 1 ? 'any of ' : '') + keys.join(', ')
end
def self.null_object
@null_object ||= NullSigner.new
end
private
class NullSigner
def signers; nil; end
def valid_signature?(s); true; end
def acceptable_signature?(s); true; end
def to_s; "Null Signer"; end
end
def signer_key
acceptable_keys.first
end
def acceptable_keys
@acceptable_keys ||= begin
keys = []
GPGME::Ctx.new do |ctx|
ctx.each_key(@signer || '', true) do |k|
keys << k if k.usable_for?([:sign])
end
keys
end
end
end
def acceptable_keyids
@acceptable_keyids ||= acceptable_keys.map { |k| k.subkeys[0].keyid }
end
end
private
class VerificationCallbacks
def initialize
yield self if block_given?
end
CALLBACKS.each do |(name, arg_count)|
args = (1..arg_count).map { |i| "arg#{i}" }.join(', ')
class_eval <<-END
def #{name}(&block)
@_#{name}_handler = block
end
def notify_#{name}(#{args})
@_#{name}_handler && @_#{name}_handler.call(#{args})
end
END
end
end
class Changes
attr_reader :directory, :changed_items, :added_items, :removed_items
def initialize(directory)
@directory = directory
@changed_items = []
@added_items = []
@removed_items = []
extend(Callbacks)
end
def immutable
# im's IVs are references to the objects as are self's,
# thus self's IVs are frozen, too.
dup.tap { |im|
im.instance_variables.each do |iv|
im.instance_variable_get(iv).freeze
end
}.freeze
end
def valid?
@valid_signature
end
def changed?
@changed
end
module Callbacks
CALLBACKS.each do |(name, arg_count)|
class_eval <<-END
def notify_#{name}(*)
end
END
end
def notify_valid_signature(*)
@valid = true
end
def notify_invalid_signature(*)
@valid = false
end
def notify_directory_changed(*)
@changed = true
end
def notify_directory_unchanged(*)
@changed = false
end
def notify_item_changed(dir, item, expected_hash, actual_hash)
@changed_items << {
:item => item, :expected_hash => expected_hash, :actual_hash => actual_hash
}
end
def notify_item_added(dir, item)
@added_items << item
end
def notify_item_removed(dir, item)
@removed_items << item
end
end
end
end
if $0 == __FILE__
require 'optparse'
Version = Checksums::VERSION
$dry_run = false
$recursive = false
$verbose = false
$check_signatures_only = false
$check_dirs_only = false
$excludes = []
$signer = nil
def dirs(root_dirs)
Checksums::CheckedRootDirs.new(root_dirs, $recursive, $excludes, $signer).
lazy.reject(&:ignored?)
end
def create_checksums(root_dirs)
puts "Creating checksums..." if $verbose
dirs(root_dirs).each do |checked_dir|
puts checked_dir if $verbose || $dry_run
checked_dir.write_checksum_file unless $dry_run
end
end
def update_checksums(root_dirs)
puts "Updating checksums..." if $verbose
dirs(root_dirs).select(&:needs_update?).each do |checked_dir|
puts checked_dir if $verbose || $dry_run
checked_dir.write_checksum_file unless $dry_run
end
end
def verify_checksums(root_dirs)
puts "Verifying checksums..." if $verbose
dirs(root_dirs).each do |checked_dir|
if $dry_run
puts checked_dir
next
end
checked_dir.verify_checksums do |on|
on.valid_signature do |dir, signer, message|
if $check_signatures_only
puts "Valid signature on #{dir}" if $verbose
throw :skip_checksum_comparison
end
end
on.invalid_signature do |dir, signer, message|
puts "Invalid signature on #{dir}."
puts "Expected signature from #{signer}"
puts "Got: #{message}"
puts
throw :skip_checksum_comparison if $check_signatures_only
end
on.directory_unchanged do |dir|
puts "unchanged #{dir}" if $verbose
end
on.directory_changed do |dir|
if $check_dirs_only
puts "DIRECTORY CHANGED #{dir}"
throw :skip_item_comparison
end
end
on.item_changed do |dir, file, expected_hash, actual_hash|
puts "ITEM CHANGED #{File.join(dir, file)}"
end
on.item_added do |dir, file|
puts "ITEM ADDED #{File.join(dir, file)}"
end
on.item_removed do |dir, file|
puts "ITEM REMOVED #{File.join(dir, file)}"
end
end
end
end
def main(args)
command = nil
loop do
case args.first
when 'create'
command = :create
args.shift
when 'update'
command = :update
args.shift
when 'verify'
command = :verify
args.shift
else
break
end
end
opts = OptionParser.new
opts.banner = "Usage: #{opts.program_name} { create | update | verify } [options] directory.."
opts.separator ''
opts.separator "General options."
opts.on('-n', '--dry-run', "Don't run any commands, just print them") {
$dry_run = true
}
opts.on('-r', '--recursive', "process sub-directories recursively") {
$recursive = true
}
opts.on('-s KEY', '--signer KEY', String, "Key used for signing and verifying signatures") { |val|
$signer = Checksums::Signer.new(val)
}
opts.on('-e', '--exclude=PATTERN', "Exclude directories (and their sub-directories)",
"matching PATTERN") { |val|
$excludes << val
}
opts.on('-v', '--verbose', "Display extra information") {
$verbose = true
}
opts.separator ''
opts.separator "Verify options:"
opts.on('-g', '--signatures-only', "Only verify signatures") {
$check_signatures_only = true
}
opts.on('-d', '--dirs-only', "Only check whether directories are unchanged") {
$check_dirs_only = true
}
root_dirs = opts.parse(args)
$signer ||= Checksums::Signer.new
case command
when :create
create_checksums root_dirs
when :update
update_checksums root_dirs
when :verify
verify_checksums root_dirs
else
puts opts.help
exit 0
end
end
main(ARGV)
end
Something went wrong with that request. Please try again.