#! /usr/bin/perl -w
#
# $Id: dvdsum 59 2007-10-06 21:21:52Z tlo $
#

use strict;
use Digest::MD5;
use Getopt::Long qw(:config no_ignore_case);
use IO::File;

my $VERSION = 1.00;
my $sums_file = "sums.md5";

my $opt;
GetOptions(
	"verify" => \$opt->{check},
	"check"  => \$opt->{check},
	"sign:s" => \$opt->{sign}
);


# Hash a single file
sub hash_file
{
	my ($file) = @_;

	my $ctx = Digest::MD5->new;
	if (!open(FILE, $file)){
		print(STDERR "Cannot open $_\n");
		return undef;
	}else{
		binmode(FILE);
		$ctx->addfile(*FILE);
		close(FILE);
	}
	return $ctx->hexdigest;
}

#
# Process files in the current directory, recursively descend each
# directory found.  Ignore the sums file (because we're currently
# writing it) if we're at the root.
#
sub hash_dir
{
	my ($fh, $dir) = @_;
	my @files;

	if (!opendir(DIR, ".")){
		return -1;
	}else{
		@files = readdir(DIR);
		closedir(DIR);
	}

	foreach my $file (@files){
		next if $file eq "." || $file eq "..";
		if (-d $file){
			if (chdir($file)){
				hash_dir($fh, $dir eq "" ? $file : "$dir/$file");
				chdir("..");
			}
		}elsif (-f $file){
			next if ($dir eq "" ? $file : "$dir/$file") eq $sums_file;
			my $hash = hash_file($file);
			if (defined($hash)){
				printf($fh "%s  %s%s\n", $hash, $dir eq "" ? "" : "$dir/", $file);
			}else{
				print(STDERR "Could not compute digest of $file\n");
				return -1;
			}
		}
	}

	return 0;
}

# Replace the sums file with a clearsigned version
sub sign_sums
{
	my ($file, $signing_key) = @_;

	my $cmd;
	if (! $signing_key){
		print("Signing with default key.\n");
		$cmd = "gpg --clearsign $file";
	}else{
		print("Signing with key $signing_key\n");
		$cmd ="gpg --clearsign --default-key $signing_key $file";
	}
	system($cmd);
	if ($? == -1){
		print(STDERR "failed to execute: $!\n");
		unlink($file);
		return -1;
	}elsif ($? & 127) {
		printf(STDERR "child died with signal %d\n", $? >> 8);
		unlink($file);
		return -1;
	}else{
		my $rc = $? >> 8;
		if ($rc != 0){
			print(STDERR "ERROR: gpg returned non-zero exit $rc for input file $file\n");
			unlink($file);
			return -1;
		}
		rename("$file.asc", $file);
	}
	return 0;
}

sub check_signature
{
	my ($file) = @_;

	system("gpg --verify $file");
	if ($? == -1){
		print(STDERR "failed to execute: $!\n");
		return -1;
	}elsif ($? & 127) {
		printf(STDERR "child died with signal %d\n", $? >> 8);
		return -1;
	}else{
		my $rc = $? >> 8;
		if ($rc != 0){
			print(STDERR "ERROR: gpg returned non-zero exit $rc for input file $file\n");
			return -1;
		}
	}
	return 0;
}


sub create_sums
{
	my ($sums_file, $dir) = @_;

	if (-f "$sums_file"){
		print(STDERR "Sums file $sums_file exists.\n");
		return -1;
	}

	my $fh = new IO::File "> $sums_file";
	if (!defined($fh)){
		print(STDERR "Cannot create sums file.\n");
		return -1;
	}else{
		hash_dir($fh, "");
		close($fh);
	}

	return 0;
}

sub check_sums
{
	my ($sums_file) = @_;
	my $have_signature = 0;
	my $warning_count = 0;
	my $error_count = 0;

	# If a different name has ever been used for the sum file then list it here
	my @sum_filenames = ( $sums_file, "md5sum", "md5sum.asc", "sums.md5", "sums.md5.asc" );

	$sums_file = undef;
	foreach my $f (@sum_filenames){
		if (-f $f){
			$sums_file = $f;
			last;
		}
	}
	if (!defined($sums_file)){
		print(STDERR "No sums file found.\n");
		return -1;
	}

	if (!open(SUMS, "<$sums_file")){
		print(STDERR "Cannot open $sums_file\n");
		return -1;
	}else{
		while (<SUMS>){
			$have_signature = 1 if /^-----BEGIN PGP SIGNED/;
			my ($old_hash, $path) = /([0-9a-fA-F]{32})\s\s(.*)/;
			next if !$old_hash && !$path;	#ignore PGP wrapper
			if (-f $path){
				my $new_hash = hash_file($path);
				if (!defined($new_hash)){
					print(STDERR "Cannot hash $path\n");
					$error_count++;
				}elsif ($new_hash ne $old_hash){
					print("$path: FAILED\n");
					$error_count++;
				}else{
					print("$path: OK\n");
				}
			}else{
				$warning_count++;
				print(STDERR "$path: WARNING, have sum but no such file was found.\n");
			}
		}
		close(SUMS);
	}

	if ($have_signature){
		print("\n");
		check_signature($sums_file)
	}

	print("\n");
	if ($warning_count == 0){
		print("There were no warnings.\n");
	}elsif ($warning_count == 1){
		print("There was 1 warning.\n");
	}else{
		print("There were $warning_count warnings.\n");
	}
	if ($error_count == 0){
		print("There were no errors detected.\n");
	}elsif ($error_count == 1){
		print("There was 1 error detected.\n");
	}else{
		print("There were $error_count errors detected.\n");
	}

	return 0;
}


my $dir = shift;
if (!defined($dir)){
	$dir = ".";
}	  
if (! -d $dir){
	print(STDERR "$dir is not a directory.\n");
	exit(1);
}

if (!chdir($dir)){
	print(STDERR "Cannot chdir($dir)\n");
	exit(1);
}else{
	if ($opt->{check}){
		check_sums($sums_file, $dir);
	}else{
		my $rc = create_sums($sums_file, $dir);
		if ($rc == 0 && defined($opt->{sign})){
			sign_sums($sums_file, $opt->{sign});
		}
	}
	chdir("..") if $dir ne ".";
	exit(0);
}

__END__

=pod

=head1 NAME

dvdsum - create a checksums file for DVD verification

=head1 SYNOPSIS

dvdsum [--check] [--sign [pgp-signing-key]] [directory]

=head1 DESCRIPTION

Dvdsum creates a sums file containing MD5 checksums of all files
within the tree rooted at the current directory, or I<directory>, for
burning to DVD along with the content files.  The sums file is written
to either the current directory or I<directory>.

Background: The author has experienced a DVD drive that would
sometimes return corrupt data without indicating an error when unable
to correct read errors.  Thus the birth of this script; the idea is
that when ready to use mkisofs(1) to create an ISO image, first write
a sums file that stores a checksum of each file for later
verification.  The sums file is optionally signed with gpg(1) to allow
detecting its corruption.

Dvdsum can be used to check the sums file to verify that the
contents of a DVD have been burnt correctly, or to verify that files
read from a DVD have not been corrupted by a bad medium or drive.
The digital signature of the sums file can also certify the
contents of a DVD.

The sums file is designed to look like the output of md5sum(1),
allowing that utility to be used to verify the checksums if this one
is not available.

=head1 OPTIONS

B<--check>

=over

Verify content files against the sums file.

=back

B<--sign [signing-key]>

=over

Use gpg to cleartext sign the sums file.  If I<signing-key> is
omitted then gpg should use whichever signing key it normally uses by
default.

=back

=head1 SEE ALSO

md5sum(1)

=head1 BUGS

There is no option to create a sums file other than B<sums.md5>.

Md5sum(1) has no recursive mode.  If it did this script wouldn't have
been written.

=head1 AUTHOR

Toomas Losin, tlo@cpan.org

Copyright 2007 Toomas Losin.  All Rights Reserved.  This software is
distributed under the terms of version 2 of the GNU General Public
License.

=begin CPAN

=head1 README

Creates a file containing MD5 checksums of all files in a directory
hierarchy for burning to DVD with the files for later verification.

=head1 PREREQUISITES

C<strict>
C<Digest::MD5>
C<Getopt::Long>
C<IO::File>

=pod OSNAMES

any

=pod SCRIPT CATEGORIES

UNIX/System_administration

=end CPAN

=cut
