#! /usr/bin/perl -w # # $Id: dvdsum 67 2007-11-02 05:00:12Z tlo $ # use strict; use Digest::MD5; use Getopt::Long qw(:config no_ignore_case); use IO::File; our $VERSION = 1.01; my $sums_file = "sums.md5"; my $opt; GetOptions( "check" => \$opt->{check}, "force" => \$opt->{force}, "sign:s" => \$opt->{sign}, "verify" => \$opt->{check} ); # 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); eval { # Eval catches I/O errors from $ctx->addfile(). We don't # do anything special with the error because the MD5 sum # will be different and that's all we care about. $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; } # Create a detached signature. sub sign_sums { my ($file, $signing_key) = @_; my $cmd; if (! $signing_key){ print("Signing with default key.\n"); $cmd = "gpg -a --detach-sign $file"; }else{ print("Signing with key $signing_key\n"); $cmd ="gpg -a --detach-sign --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; } } 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 == 1){ return -2; }elsif ($rc == 2){ return -3; }elsif ($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; } # # If the specified sums file isn't found then search the list of # historic sums files, returning the first match. # sub find_sums_file { my ($sums_file) = @_; 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; } } return $sums_file; } # Walk the given sums file and verify each sum. sub check_sums { my ($sums_file) = @_; my $warning_count = 0; my $error_count = 0; if (!open(SUMS, "<$sums_file")){ print(STDERR "Cannot open $sums_file\n"); return -1; }else{ while (){ my ($old_hash, $path) = /([0-9a-fA-F]{32})\s\s(.*)/; next if !$old_hash && !$path; #ignore PGP wrapper on older files 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"); } } if ($! != 0){ # if we get an error reading the sums file, report it print(STDERR "Error reading $sums_file: $!\n"); $error_count++; } close(SUMS); } 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}){ my $sums_file_found = find_sums_file($sums_file, $dir); if (defined($sums_file_found)){ my $good_signature = 1; if (-f "$sums_file_found.asc"){ my $rc = check_signature("$sums_file_found.asc"); print("\n"); if ($rc == -1){ print("Unexpected error signature checking sums file $sums_file_found\n"); print("Use --force to check the sums anyway.\n") if !$opt->{force}; print("\n"); $good_signature = 0; }elsif ($rc == -2){ print("The sums file $sums_file_found fails the digital signature check.\n"); print("The data may be compromised.\n"); print("Use --force to check the sums anyway.\n") if !$opt->{force}; print("\n"); $good_signature = 0; }elsif ($rc == -3){ print("Could not verify the digital signature for $sums_file_found.\n"); print("Use --force to check the sums anyway.\n") if !$opt->{force}; print("\n"); $good_signature = 0; } } if ($good_signature || $opt->{force}){ check_sums($sums_file_found, $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, for burning to DVD along with the content files. The sums file is written to either the current directory or I. 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 able to verify the checksums. =head1 OPTIONS B<--check> =over Verify content files against the sums file. =back B<--sign [signing-key]> =over Use gpg to create a detached signature of the sums file. If I 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. Md5sum(1) has no recursive mode. If it did then this script would not have been written. A detached signature is produced by --sign instead of a clean-filesystem friendly cleartext signature in order to prevent someone from taking advantage of user confusion by prepending or appending MD5 sums that appear to pass signature verification when, per spec, such data isn't covered by the signature. =head1 AUTHOR Toomas Losin, tlo@cpan.org, http://www.lenrek.net/ 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 C C C =pod OSNAMES any =pod SCRIPT CATEGORIES UNIX/System_administration =end CPAN =cut