#!/usr/bin/perl -w # Don't edit the next line; use "make version" instead. $VERSION="0.9.0"; =pod =head1 NAME sbolint - check SlackBuild directories or tarballs for common errors. =head1 SYNOPSIS B [--help | --man | --version ] | [-a] [-q] [-u] [-e] [-n] [-r] [-c] [-m] [build [build ...]] =head1 DESCRIPTION B checks for common errors in SlackBuilds.org scripts. It's intended for SlackBuild authors and maintainers, and can cut down on "There was a problem with your upload" errors from the submission form. The [build] arguments must be either directories or tarballs, each containing a SlackBuild script, slack-desc, README, and .info file. With no [build] arguments, the current directory is checked. To read a list of builds from standard input, use B<->. B will flag errors for problems that would prevent the build from being accepted by the upload form (or by the SBo admins, if if passes the upload checks). There may also be warnings, which are things that (probably) won't stop your build from being accepted, but may cause the SBo admins extra work. There are also notes, which are things that won't generally do any harm, but might be worth investigating and cleaning up. B shares no code with the upload form's submission checker. Lack of errors/warnings from B does not guarantee that your build will be accepted! B doesn't check built packages, and never executes the build script. If you want a lint tool for binary Slackware packages, use B. Note that B is intended to help you, not control you. If your build fails the tests, and you're 100% sure it's correct, please contact the maintainer (B) and explain the situation. Most likely, B will be modified to accomodate your code, unless something really is wrong with it. =head1 OPTIONS Bundling options is supported, e.g. B<-qr> is the same as B<-q -r>. =over 4 =item B<-a>, B<--all> Check all builds in the git repository. This must be run from within a git tree (e.g. one made with "git clone"). This option also turns on B<-q> (quiet), and (by default) turns off color, though you can enable it again with B<-c>. Also, if standard output is a terminal, B will redirect stdout to a file called BI (the current date). =item B<-q>, B<--quiet> Quiet. Suppresses 'xxx checks out OK' and the total errors/warnings summary. =item B<-u>, B<--url-head> URL check. Uses B to make HTTP HEAD requests for the B, B, and B links. This won't guarantee that the links are good, but some kinds of failure (e.g. site down, 404) mean they're definitely bad. Unfortunately a lot of sites have stopped responding to HEAD requests in the name of "security", so your mileage may vary. =item B<-e>, B<--no-warnings> Suppress all warnings and notes. Only errors will be listed. This also affects the exit status (see below). =item B<-r>, B<--no-readme> Suppress the warning about README lines being too long. Other warnings will still be emitted. =item B<-n>, B<--no-notes> Suppress notes. Only errors and warnings will be listed. =item B<-c>, B<--color> Always use colorful text, even if standard output is not a terminal. The default is to auto-detect. =item B<-m>, B<--mono>, B<--no-color> Don't use color, even if standard output is a terminal. =item B<--version> Show version number and exit. =item B<--help>, B<--doc> Show this documentation in your pager. =item B<--man> Convert this documentation to a man page, on stdout. =back =head1 CHECKS For tar files only: =over 4 =item - File size must not be bigger than the upload form's limit (currently one megabyte). =item - File must be a tar archive, possibly compressed with gzip, bzip2, or xz, extractable by the B(1) command. =item - Filename extension must match compression type. =item - Archive must contain a directory with the same name as the archive's base name, e.g. I must contain I. Everything else in the archive must be inside this directory. =item - Archive must contain I. =item - Any files in the archive other than the .SlackBuild, .info, slack-desc, and README are checked for permissions (should be 0644) and excessive size. =item - The source archive(s) must not exist in the archive. Also sbolint attempts to detect extracted source trees (but isn't all that good at it). =item - "Junk" files such as editor backups and core dumps must not exist in the archive. =item - Files named 'build.log*' or 'strace.out*' must not exist in the archive. The B tool creates these. =back For all builds: =over 4 =item - The directory may not contain empty directories, empty (0-byte) files, or hidden files/directories (named with a leading B<.>). =item - The SlackBuild, .info, README files must have Unix \n line endings (not DOS \r\n), and the last line of each must have a \n. =item - The SlackBuild script must exist, with mode 0644 or 0755 (or 0644 only, if in a git repo), and be a I<#!/bin/bash> script. =item - The script must contain the standard variable assignments for PRGNAM, VERSION, BUILD, and TAG. BUILD must be numeric. =item - I in the script must match I in the .info file. Both must match the script name (I) and the directory name. =item - I must match the I in the .info file. =item - I, I, I, and I must occur in the script. =item - The I and I variables must respect the environment. =item - The script must contain the I section. =item - The script must assign I and I before the I section. =item - The script must install the slack-desc in I<$PKG/install>. =item - Template boilerplate comments should be removed, e.g. I<"REMOVE THIS ENTIRE BLOCK OF TEXT"> or I<"Automatically determine the architecture we're building on">. =item - Script must contain exactly one B command. =item - ARCH=i386 or ARCH=i486 is either a warning or an error, depending on whether -march= is used in the script. The idea is that compiling for i386 or i486 is an error, but binary repacks that use an outdated ARCH just draw a warning (this "grandfathers in" older builds in the repo that haven't been updated). =item - README must exist, have mode 0644, its character encoding must be either ASCII or UTF-8 without BOM, and it may not contain tab characters. =item - slack-desc must exist, have mode 0644, its character encoding must be ASCII, and it may not contain tab characters. =item - slack-desc contents must match the SBo template, including the "handy-ruler", comments, and correct spacing/indentation. =item - The short description in the first line of the slack-desc must not be literally "short description of app" (copied from the template and not edited). =item - .info file must exist, have mode 0644, and match the SBo template. =item - .info file URLs must be valid URLs (for a very loose definition of "valid": they must begin with B, B, or B). =item - Optionally, .info file URLs can be checked for existence with an HTTP HEAD request (see the B<-u> option). =item - If there is a doinst.sh script: =over 4 =item - The SlackBuild must install doinst.sh to I<$PKG/install>. =item - If doinst.sh uses the config() and/or preserve_perms() functions, the function must be defined before it's used. Also, config files passed to config() or preserve_perms() must end with a B<.new> suffix. =item - If doinst.sh calls B, the existence of the cache must be checked before this command is run. =back =back =head1 EXIT STATUS Exit status from sbolint will normally be 0 (success) if there were no errors or warnings in any of the builds checked. With the B<-n> option, exit status will be 0 if there are no errors. Exit status 1 indicates there was at least one warning or error (or, with B<-n>, at least one error). Notes do not affect the exit status. Any other exit status means sbolint itself failed somehow (e.g. called with nonexistent filename). =head1 BUGS Probably quite a few. Watch this space for details. =head1 AUTHOR B. Watson , aka Urchlay on Libera IRC. =head1 SEE ALSO B(1), B, B(1), B(8), B(1) =cut # These perl modules ship with Slackware's perl package. use POSIX qw/getcwd/; use Getopt::Long qw/:config gnu_getopt no_require_order/; @boilerplate = ( qr/#\s*REMOVE THIS ENTIRE BLOCK OF TEXT/, qr/#\s*replace with (?:version:name) of program/, qr/#\s*the "_SBo" is required/, qr/#\s*Automatically determine the architecture we're building on/, qr/#\s*Unless \$ARCH is already set,/, qr/#\s*For consistency's sake, use this/, qr/#\s*Drop the package in \/tmp/, qr/#\s*Exit on most errors/, qr/#\s*If you prefer to do selective error checking with/, qr/#\s*Your application will probably need/, qr/#\s*Compile the application and install it into the/, qr/#\s*Strip binaries and libraries - this can be done with/, qr/#\s*Compress man pages$/, qr/#\s*Compress info pages and remove the/, qr/#\s*Copy program documentation into the package/, qr/#\s*Copy the slack-desc \(and a custom doinst\.sh if necessary\)/, qr/#\s*Make the package; be sure to leave it in/, ); # this was scraped from the HTML source for the upload form: $MAX_TARBALL_SIZE = 1048576; ($SELF = $0) =~ s,.*/,,; $buildname = $build = ""; $g_warncount = 0; $g_errcount = 0; $warncount = 0; $errcount = 0; $progress_bar = 0; $tempdir = 0; our %info = (); # has to be global, check_info sets it, check_script needs it # undef means autodetect. -c/-m set it explicitly, -a only affects it if # it's still undef (if no -c or -m precedes -a on the command line). $color_output = undef; # main() { GetOptions( "all|a" => sub { $recursive_git = 1; $color_output = 0 unless defined $color_output; }, "url-head|u" => \$url_head, "download|d" => \$url_download, "quiet|q" => \$quiet, "version|v" => sub { print "$VERSION\n"; exit 0; }, "no-warnings|e" => sub { $nowarn = 1; $nonote = 1;}, "no-notes|n" => \$nonote, "no-readme|r" => \$suppress_readme_len, "doc|help|h" => sub { exec("perldoc $0"); }, "man" => sub { exec("pod2man --stderr -s1 -csbo-maintainer-tools -r$VERSION $0"); }, "color|colour|c" => sub { $color_output = 1; }, "mono|no-color|no-colour|m" => sub { $color_output = 0; }, ) or die_usage("Error in command line options"); $color_output = -t STDOUT unless defined $color_output; if(@ARGV && $ARGV[0] eq '-') { $stdin = 1; } if($color_output) { $red = "\x1b[1;31m"; $yellow = "\x1b[1;33m"; $green = "\x1b[1;32m"; $color_off = "\x1b[0m"; } else { $red = ""; $yellow = ""; $green = ""; $color_off = ""; } if($url_head && $url_download) { die_usage("-u and -d options are mutually exclusive"); } if($url_head || $url_download) { if(system("curl --version > /dev/null") != 0) { die "$SELF: -u and -d options require curl, can't find it in your \$PATH.\n"; } } if($stdin) { @ARGV = ; chomp for @ARGV; } if($recursive_git) { if(-t STDOUT) { chomp (my $outfile = "$SELF.log." . `date +%Y%m%d`); warn "$SELF: stdout is a terminal, redirecting to $outfile\n"; if(rename $outfile, $outfile . ".old") { warn "$SELF: renamed old $outfile to $outfile.old\n"; } open my $f, ">$outfile" or die $!; *STDOUT = $f; } @ARGV=(); my $pwd; # find root of the SBo git repo, if we're somewhere inside it. while(! -d ".git" && ! -d "system") { chdir(".."); chomp($pwd = `pwd`); die "$SELF: -a option only works if you run $SELF from a git worktree\n" if $pwd eq "/"; } chomp($pwd = `pwd`); for(`git ls-files '*/*/*.SlackBuild' | cut -d/ -f1,2`) { chomp; push @ARGV, $_; } warn "$SELF: linting " . scalar(@ARGV) . " builds from git repo at $pwd\n" unless $quiet; $quiet = $progress_bar = 1; } push @ARGV, "." unless @ARGV; $argv_count = 0; $err_warn_count = 0; for(@ARGV) { run_checks($_); $g_errcount += $errcount; $g_warncount += $warncount; if($errcount || $warncount) { $err_warn_count++; } if(!$quiet) { if($errcount == 0 and $warncount == 0) { print "$SELF: $buildname checks out " . $green . "OK" . $color_off . "\n"; } else { print "$SELF: $buildname: " . $red . "errors $errcount" . $color_off . ", " . $yellow . "warnings $warncount" . $color_off . "\n"; } } if($progress_bar) { $argv_count++; my $pct = ($argv_count / @ARGV) * 100; printf STDERR " %d/%d, %2.1f%%\r", $argv_count, scalar @ARGV, $pct; } } if($progress_bar) { my $t = scalar @ARGV; my $ok = $t - $err_warn_count; printf STDERR "\n%d builds linted. %d (%.2f%%) OK, %d (%.2f%%) failed.\n", $t, $ok, 100 * (($ok) / $t), $err_warn_count, 100 * ($err_warn_count / $t); chomp (my $now = `date +%s`); my $elapsed = $now - $^T; my $min = int($elapsed / 60); my $sec = $elapsed % 60; printf STDERR "Elapsed time %d:%02d\n", $min, $sec; } # print total errs/warns only if >1 build checked if(!$quiet && @ARGV > 1) { print "$SELF: Total errors: $g_errcount\n"; print "$SELF: Total warnings: $g_warncount\n" unless $nowarn; } exit ($g_errcount > 0 || (!$nowarn && $g_warncount > 0)); # } sub dequote { my $a = shift; #warn "dequote arg: $a\n"; $a =~ s/^("|')(\S+)(\1)$/$2/; #warn "dequote ret: $a\n"; return $a; } sub logmsg { my $severity = shift; my $format = shift; printf("$buildname: $severity: $format\n", @_); } sub log_error { logmsg($red . "ERR" . $color_off, @_); $errcount++; } sub log_warning { return if $nowarn; logmsg($yellow . "WARN" . $color_off, @_); $warncount++; } sub log_note { return if $nowarn || $nonote; logmsg($green . "NOTE" . $color_off, @_); } sub die_usage { die "$SELF: $_[0] (try --help)\n" } sub chdir_or_die { chdir($_[0]) or die "$SELF: chdir($_[0]): $!\n"; } sub make_temp_dir { return if $tempdir; my $tmp = $ENV{TMP} || "/tmp"; $tempdir = "$tmp/$SELF." . int(rand(2**32-1)); system("rm -rf $tempdir"); system("mkdir -p $tempdir"); if(! -d $tempdir) { die "$SELF: can't create temp dir $tempdir\n"; } } sub rm_temp_dir { if($tempdir && (-d $tempdir)) { system("rm -rf $tempdir"); $tempdir = 0; } } sub check_tarball_mime { my $file = shift; ### This stuff is a little pedantic. It also relies on having a recent-ish ### version of GNU file (the one in Slack 15.0 works fine). my %types = ( 'tar' => 'application/x-tar', 'tar.gz' => 'application/gzip', 'tar.bz2' => 'application/x-bzip2', 'tar.xz' => 'application/x-xz', ); (my $basename = $file) =~ s,.*/,,; if($basename =~ /^\./) { log_error("$file: tarball filename begins with a . (hidden, WTF?)"); return; } my $ext; if($basename =~ /\.(tar(?:\.(?:gz|bz2|xz))?)$/) { $ext = $1; } else { log_error("$file: bad tarball filename, not .tar or .tar.(gz|bz2|xz), will be rejected by upload form"); return; } ### warn "\$basename is \"$basename\", \$ext is \"$ext\""; my $mime = `file --brief --mime-type $file`; chomp $mime; if(!grep { $_ eq $mime } values %types) { log_error("$file is not a tarball (mime type is '$mime')"); } elsif($types{$ext} ne $mime) { log_error("$file mime type '$mime' doesn't match filename (should be $types{$ext})"); } elsif($ext ne 'tar') { my $realmime = `file -z --brief --mime-type $file`; chomp $realmime; if($realmime ne 'application/x-tar') { log_error("$file doesn't contain a tar archive (content mime type is $realmime, should be application/x-tar)"); } } } sub check_tarball { my $file = shift; ### First, mime type checks. None of this will be fatal (no return 0 on error). check_tarball_mime($file); ### one more pre-extraction check: if(-s "$file" > $MAX_TARBALL_SIZE) { log_warning("$file is larger than $MAX_TARBALL_SIZE bytes, upload may be rejected"); } ### now call tar to list the contents, and start returning 0 on failure. my @list = split "\n", `tar tf $file`; if($?) { log_error("$file: tar failed to list contents"); return 0; } if(!@list) { log_error("$file is empty archive?"); return 0; } ### Used to do this: # if($list[0] ne "$buildname/") { # log_error("$file not a SBo-compliant tarball, first element should be '$buildname/', not '$list[0]'"); # return 0; # } ### ...but it turns out some tools (file-roller) put the directory *last* ### in the tarball. And furthermore, you can easily create a tarball ### without the directory at all (try "tar cvf dir/*", there will be no ### separate entry for dir/). There's nothing wrong with such tarballs, ### tar can extract them just fine, so we allow them here. my $foundsb = 0; ### shift @list; # 1st element is dirname/, we already checked it for(@list) { my $bn = quotemeta($buildname); # some builds have + in the name if(not /^$bn\//) { log_error("$file not a SBo-compliant tarball, contains extra junk '$_'"); return 0; } if(/^$bn\/$bn.SlackBuild$/) { $foundsb = 1; } } if(not $foundsb) { log_error("$file not a SBo-compliant tarball, doesn't contain '$buildname/$buildname.SlackBuild'"); return 0; } return 1; } sub extract_tarball { my $file = shift; $file = `readlink -n -e $file`; make_temp_dir(); chdir_or_die($tempdir); system("tar xf $file"); return "$tempdir/$buildname"; } # run_checks will extract its argument (then cd to it) if it's a tarball, # otherwise cd to its argument if it's a dir, otherwise error. sub run_checks { $build = shift; my $checking_tarball = 0; my $in_git_repo = 0; my $oldcwd = getcwd(); $errcount = $warncount = 0; if(-f $build || -l $build) { ($buildname = $build) =~ s,\.tar(\..*)?$,,; $buildname =~ s,.*/,,; if(check_tarball($build)) { chdir_or_die(extract_tarball($build)); $checking_tarball++; } else { return 0; } } elsif(-d $build) { chdir_or_die($build); } else { die_usage "'$build' not a file or a directory."; } # last component of directory is the build name $buildname = `readlink -n -e .`; $buildname =~ s,.*/,,; # are we in a git repo? build scripts are mode 0644 there, plus # the junkfile check is skipped. if(!$checking_tarball) { $in_git_repo = system("git rev-parse >/dev/null 2>/dev/null") == 0; } # what permissions are allowed for the SlackBuild? 3 choices: # in a tarball, it has to be 755. # in a git repo, it has to be 644. # anywhere else, 644 and 755 are allowed. if($checking_tarball) { @script_perms = (0755); } elsif($in_git_repo) { @script_perms = (0644); } else { @script_perms = (0644, 0755); } if(script_exists()) { my @checks = ( \&check_readme, \&check_slackdesc, \&check_info, \&check_script, \&check_doinst, \&check_images, \&check_empty_hidden, ); # we only care about the junkfiles check for tarballs; don't # try to second-guess the user otherwise. push @checks, \&check_junkfiles if $checking_tarball; for(@checks) { $_->($build); } } chdir_or_die($oldcwd); rm_temp_dir(); } sub check_mode { my ($file, $wantmode) = @_; if(! -e $file) { log_error("$file does not exist"); return 0; } my $gotmode = 07777 & ((stat($file))[2]); if($wantmode != $gotmode) { log_error("$file must be mode %04o, not %04o", $wantmode, $gotmode); return 0; } return 1; } sub check_crlf { my $file = shift; for(@_) { if(/\r/) { log_error("$file has DOS-style CRLF line endings"); return 0; } } return 1; } sub check_and_read { my ($file, $mode) = @_; my $crlf_err; my @lines; my $lastline_nonl; check_mode($file, $mode) if defined $mode; if(open my $fh, "<$file") { while(<$fh>) { $lastline_nonl = 1 unless /\n$/; chomp; $crlf_err = 1 if s/\r$//; push @lines, $_; } if(scalar @lines == 0) { log_error("$file exists but is empty"); } } log_error("$file has DOS-style CRLF line endings") if $crlf_err; log_error("$file has no newline at EOF") if $lastline_nonl; return @lines; } # 20220315 bkw: warn if a file isn't ASCII or UTF-8 without BOM. # Used for README and slack-desc... sub check_encoding { my $file = shift; my $ascii_only = shift; my $ftype; # 20220314 bkw: the -e options make file faster and turn off checks # we don't need, ones that sometimes cause false detection too. chomp($ftype = `file -b -e cdf -e compress -e csv -e elf -e json -e soft -e tar $file`); if($ascii_only && ($ftype !~ /ASCII text/)) { log_warning("$file must be ASCII text, not $ftype"); } if($ftype =~ /ASCII text/ || $ftype =~ /UTF-8/) { # encoding is OK, but: if($ftype =~ /BOM/) { log_warning("$file has BOM, remove with: LANG=C sed -i '1s/^\\xEF\\xBB\\xBF//' $file"); } } elsif($ftype =~ /ISO-8859/) { log_warning("$file has ISO-8859 encoding, fix with: mv $file $file.old; iconv -f iso-8859-1 -t utf-8 $file.old > $file; rm $file.old"); } else { log_warning("$file isn't ASCII or UTF-8, file(1) says it's '$ftype'"); } } sub check_readme { my $maxlen = $ENV{'SBOLINT_README_MAX'} || 72; my @lines = check_and_read("README", 0644); return unless @lines; check_encoding("README", 0); if(grep { /\t/ } @lines) { log_warning("README has tabs, these should be replaced with spaces"); } return if $suppress_readme_len; # 20220205 bkw: don't complain about long lines if they're URLs, # not much we can do about them. if(grep { !/^\s*(ftp|https?):\/\// && length > $maxlen } @lines) { log_warning("README has lines >$maxlen characters"); } } # the slack-desc checking code offends me (the author), on the one hand it's # overly complex, and on the other hand it assumes the slack-desc is at # least close to being right... sub check_slackdesc { my @lines = check_and_read("slack-desc", 0644); return unless scalar @lines; check_encoding("slack-desc", 1); if(grep { /\t/ } @lines) { log_warning("slack-desc has tabs, these should be replaced with spaces"); } my $lineno = 1; if($lines[0] =~ /^# HOW TO EDIT THIS FILE:$/) { shift @lines; $lineno++; } else { log_warning("slack-desc doesn't start with how-to-edit comment"); } my $count = 0; while($lines[0] =~ /^#/) { $count++; $lineno++; shift @lines; } if($count != 5) { log_warning("slack-desc doesn't have standard how-to-edit stanza"); } $count = 0; while($lines[0] eq "") { $count++; $lineno++; shift @lines; } if($count == 0) { log_warning("slack-desc missing blank line before handy-ruler"); } elsif($count > 1) { log_warning("slack-desc has extra blank lines before handy-ruler"); } if($lines[0] =~ /handy-ruler/) { my $ruler = shift @lines; $lineno++; my ($spaces, $prefix, $hr, $suffix, $junk) = ($ruler =~ /^( *)(\|-+)(handy-ruler)(-+\|)(.*)$/); if(length($spaces) != length($buildname)) { log_error("slack-desc:$lineno: handy-ruler has wrong number of indent spaces (%d, should be %d)", length($spaces), length($buildname)); } if(length($junk) > 0) { log_error("slack-desc:$lineno: handy-ruler has %d characters of trailing junk after last |", length($junk)); } my $rlen = length($prefix . $hr . $suffix); if($rlen != 72) { log_error("slack-desc:$lineno: handy-ruler must be 72 characters, not %d", $rlen); } elsif(length($prefix) != 6) { log_error("slack-desc:$lineno: handy-ruler malformed, has '$prefix' instead of '|-----'"); } } else { log_error("slack-desc missing handy-ruler"); } $count = 0; for(@lines) { $count++; if(my ($prefix, $text) = /^([^\s]+:)(.*)/) { if($prefix ne "$buildname:") { log_error("slack-desc:$lineno: wrong prefix '$prefix', should be '$buildname:'"); } elsif($text =~ /^\s+$/) { log_error("slack-desc:$lineno: trailing whitespace after colon, on otherwise-blank line"); } elsif(length($text) && $text !~ /^ /) { log_error("slack-desc:$lineno: missing whitespace after colon, on non-blank line"); } elsif(/\(short description of app\)/) { log_error("slack-desc:$lineno: bad description: literally 'short description of app'"); } else { # The world is not ready for this: ###if($text =~ /\s$/) { ### log_error("slack-desc:$lineno: trailing whitespace on description line"); ###} if(length($text) > 71) { log_error("slack-desc:$lineno: text too long, %d characters, should be <= 71", length($text)); } } my $bn = quotemeta($buildname); # some builds have + in the name if(($count == 1) && ($text !~ /^ $bn \(.+\)$/)) { log_warning("slack-desc:$lineno: first description line should be '$buildname: $buildname (short desc)'"); } } else { log_error("slack-desc:$lineno: malformed line in description section"); } $lineno++; } if($count < 11) { log_error("slack-desc only has $count description lines, should be 11 (add some empties)"); } elsif($count > 11) { log_error("slack-desc has too many description lines ($count, should be 11)"); } } # This is a damn mess. Needs refactoring badly. sub check_info { my $file = $buildname . ".info"; my @lines = check_and_read($file, 0644); return unless scalar @lines; my $lineno = 0; my @expected = qw/PRGNAM VERSION HOMEPAGE DOWNLOAD MD5SUM DOWNLOAD_x86_64 MD5SUM_x86_64 REQUIRES MAINTAINER EMAIL/; my $next_exp = 0; my @keys; my $continuation = 0; my $fixable = 0; # parse and bitch about bad syntax... for(@lines) { $lineno++; if($continuation) { s/^\s*//; $_ = "$continuation $_"; $continuation = 0; } if(s/\s*\\$//) { $continuation = $_; next; } if(/^\s*$/) { log_error("$file:$lineno: blank line (get rid of it)"); $fixable++; next; } unless(/=/) { log_error("$file:$lineno: malformed line (no = sign, missing \\ on prev line?)"); $fixable++; next; } if(s/^\s+//) { log_error("$file:$lineno: leading whitespace before key"); $fixable++; } if(s/\s+$//) { log_error("$file:$lineno: trailing whitespace at EOL"); $fixable++; } if(my ($k, $s1, $s2, $q1, $val, $q2) = /^(\w+)(\s*)=(\s*)("?)(.*?)("?)$/) { if(!grep { $k eq $_ } @expected) { log_error("$file:$lineno: invalid key '$k'"); } else { if($k ne $expected[$next_exp]) { log_warning("$file:$lineno: out of order, expected $expected[$next_exp], got $k"); $fixable++; } $next_exp++; } if(not $q1) { log_error("$file:$lineno: missing opening double-quote"); $fixable++; } if(not $q2) { log_error("$file:$lineno: missing closing double-quote"); $fixable++; } if(length($s1) || length($s2)) { log_error("$file:$lineno: no spaces allowed before/after = sign"); $fixable++; } my $oldval = $val; if($val =~ s/^\s+//) { log_error("$file:$lineno: leading space in value: \"$oldval\""); $fixable++; } if($val =~ s/\s+$//) { log_error("$file:$lineno: trailing space in value: \"$oldval\""); $fixable++; } $info{$k} = $val; } else { log_error("$file:$lineno: malformed line"); $fixable++; } } if($fixable) { logmsg("NOTE", $fixable . " possibly-fixable errors found. suggest running sbofixinfo."); } # parsing done, now for semantic checks my @missing; for(@expected) { if(not exists($info{$_})) { push @missing, $_; } } log_error("$file: missing required key(s): " . (join ", ", @missing)) if @missing; # init this to avoid checking undef values below $info{$_} ||= "" for @expected; if($info{PRGNAM} && ($info{PRGNAM} ne $buildname)) { log_error("$file: PRGNAM is '$info{PRGNAM}', should be '$buildname'"); } if($info{PRGNAM} =~ /[^-+._A-Za-z0-9]/) { log_error("$file: PRGNAM has invalid characters, only A-Z, a-z, 0-9, - + . _ are allowed"); } if($info{VERSION} =~ /-/) { log_error("$file: VERSION may not contain - (dash) characters"); } if(!check_url($info{HOMEPAGE})) { log_error("$file: HOMEPAGE=\"%s\" doesn't look like a valid URL (http, https, or ftp)", $info{HOMEPAGE}); } # use a HEAD request for homepage, even if downloading other files if($url_head || $url_download) { curl_head_request($file, $info{HOMEPAGE}) || do { log_warning("$file: HOMEPAGE URL broken?"); }; } if($info{MD5SUM} =~ /^\s*$/) { log_error("$file: MD5SUM is missing or blank") unless $info{DOWNLOAD} eq 'UNSUPPORTED'; } else { check_dl_and_md5($file, ""); } my $dl64 = $info{DOWNLOAD_x86_64}; if($dl64 =~ /^(?:|UNSUPPORTED|UNTESTED)$/) { if($info{MD5SUM_x86_64} ne "") { log_error("$file: MD5SUM_x86_64 must be blank if DOWNLOAD_x86_64 is not set"); } } elsif($info{MD5SUM_x86_64} eq "") { log_error("$file: MD5SUM_x86_64 may not be blank if DOWNLOAD_x86_64 is set"); } else { check_dl_and_md5($file, "_x86_64"); } } sub check_dl_and_md5 { my($file, $suffix) = @_; my $md5key = "MD5SUM" . $suffix; my $dlkey = "DOWNLOAD" . $suffix; my @dlurls = split /\s+/, $info{$dlkey}; my @md5s = split /\s+/, $info{$md5key}; if(@md5s != @dlurls) { log_error("$file: we have " . @dlurls . " $dlkey URLs but " . @md5s . " $md5key" . " values"); } for my $u (@dlurls) { if(!check_url($u)) { log_error("%s: %s URL \"%s\" doesn't look like a valid URL (http, https, or ftp)", $file, $dlkey, $u); next; } #check_github_url($file, $u); if($url_head) { curl_head_request($file, $u) || do { #warn '$u is '. $u; log_warning("%s: %s URL \"%s\" broken?", $file, $dlkey, $u); }; } elsif($url_download) { warn "$SELF: -d option not yet implemented\n"; } } for(@md5s) { unless(/^[0-9a-f]{32}$/) { log_error("$file: $md5key '$_' is invalid (must be 32 hex digits)"); } } # TODO: maybe actually download and check md5sums. } sub check_url { # url is bad if: return 0 if $_[0] =~ /\s/; # ...it contains a space, return 0 if $_[0] !~ /\./; # ...it has no dots, or return 0 if $_[0] !~ /\//; # ...it has no slashes, or return 0 if $_[0] =~ /\$/; # ...it contains a dollar sign, or return ($_[0] =~ /^(?:ftp|https?):\/\//); # ...it doesn't have a known protocol, # ...which doesn't necessarily mean it's a good URL either. } sub curl_head_request { my $file = $_[0]; my $client_filename = $_[1]; $client_filename =~ s,.*/,,; my @curlcmd = qw/curl --max-time 20 --head --location --silent --fail/; push @curlcmd, $_[1]; open my $pipe, "-|", @curlcmd; while(<$pipe>) { chomp; s/\r//; if(/^content-disposition:\s+attachment;\s+filename=["']?(.*?)["']?$/i) { #warn $1; if(defined($client_filename) && ($client_filename ne $1)) { log_warning("%s: download filename varies based on content disposition: '%s' vs. '%s'", $file, $1, $client_filename); } } } return close($pipe); } # WIP, maybe no longer needed ## sub check_github_url { ## my $file = shift; ## my $url = shift; ## return unless $url =~ m{(https?:)//github\.com}; ## ## if($1 eq "http:") { ## log_warning("$file: github URL $url should be https"); ## } ## ## (my $expect_filename = $url) =~ s,.*/,,; ## my(undef, undef, undef, $user, $prog, $archive, $ver, $filename) = split /\//, $url; ## warn "user $user, prog $prog, archive $archive, ver $ver, filename $filename, expect_filename $expect_filename\n"; ## ## # assume these are correct, for now ## return if $user eq 'downloads'; ## return if $archive eq 'releases'; ## ## # TODO: work out what to do about /raw/ ## return if $archive eq 'raw'; ## ## if($archive ne 'archive') { ## log_warning("$file: unknown github URL type: $url"); ## return; ## } ## ## # OK, good URLs look like this: ## # https://github.com/jeetsukumaran/DendroPy/archive/v4.4.0/DendroPy-4.4.0.tar.gz ## # ...and bad ones look like this: ## # https://github.com/haiwen/seafile-client/archive/v4.4.2.tar.gz ## # Corrected version of the bad one would be: ## # https://github.com/haiwen/seafile-client/archive/v4.4.2/seafile-client-4.4.2.tar.gz ## # Notice the "v" isn't part of the version number. It's not always there, ## # and sometimes it's a different letter (r, or g, or capital V, etc). ## } sub script_exists { my $file = $buildname . ".SlackBuild"; unless(-e $file) { log_error("$file does not exist, this is not a valid SlackBuild dir"); return 0; } return 1; } # NOT going to police the script too much. Would end up rewriting most of # the shell, in perl. Plus, it'd become a straitjacket. Here's what I'll # implement: # - #!/bin/bash on line 1 # - PRGNAM must match $buildname # - VERSION must match the .info VERSION # - BUILD line must be present # - TAG line must be present # - If VERSION, BUILD, TAG don't respect the env, it's a warning # - Check for strings like slack-desc, $PKG/install, makepkg, stuff # that's standard for SBo. Don't be too specific here. # - If there's a doinst.sh, it must mentioned in the script. If not, # it better not be mentioned. # - Check for leftover boilerplate # - cp -a is an error sub check_script { my $file = $buildname . ".SlackBuild"; my $gotmode = 07777 & ((stat($file))[2]); my $mode_ok = 0; my @octalmodes = (); for(@script_perms) { push @octalmodes, sprintf("%04o", $_); $mode_ok++ if $gotmode == $_; } # warn "allowed modes: " . join(", ", @octalmodes); if(!$mode_ok) { my $modes = join " or ", @octalmodes; log_error("$file must have mode $modes, not %04o", $gotmode); } my @lines = check_and_read($file); return unless scalar @lines; if($lines[0] !~ /^#!/) { log_error("$file:1: missing or invalid shebang line (should be '#!/bin/bash')"); } elsif($lines[0] !~ m,#!/bin/bash(?: (?:-e|-eu|-ue|-e -u|-u -e))?$,) { log_warning("$file:1: shebang line should be #!/bin/bash (possibly with -e/-u arg(s)), not '$lines[0]'"); } my $lineno = 0; my ($prgnam, $version, $build, $tag, $need_doinst, $need_douninst, $slackdesc, $makepkg, $install); my ($cdpkg, $codestart, $lint_enabled, $print_pkg_name, $pkg_type, $arch_lineno); my ($old_arch, $old_flags, $have_py2, $have_py3); my ($libsuf_set, $flags_set, $libsuf_used, $flags_used); $lint_enabled = 1; for(@lines) { $lineno++; if(/^\s*[^#]/ && !defined($codestart)) { $codestart = $lineno; if(not /^cd\s+"?\$\(\s*dirname\s+"?\$0.*CWD=.*pwd/) { log_error("$file:$lineno: first line of code must be 'cd \$(dirname \$0) ; CWD=\$(pwd)'"); } } if(/^###sbolint\s*(\S+)/) { my $arg = $1; if(lc($arg) eq "on") { $lint_enabled = 1; } elsif(lc($arg) eq "off") { $lint_enabled = 0; } else { log_warning("$file:$lineno: unknown ###sbolint argument '$arg' (should be 'on' or 'off')"); } } next unless $lint_enabled; # TODO: cp without -a (or -p, or a couple other flags) is OK. ## if(/^[^#]*cp\s+(?:-\w+\s+)*[\"\$\{]*CWD/) { ## log_error("$file:$lineno: copying files from CWD with cp (use cat instead)"); ## } if(/^PRGNAM=(\S+)/) { if($prgnam) { log_error("$file:$lineno: PRGNAM redefined"); } $prgnam = dequote($1); if($prgnam ne $buildname) { log_error("$file:$lineno: PRGNAM doesn't match dir name ($prgnam != $buildname)"); } } elsif(/^VERSION=(\S+)/ && ($lineno <= $codestart + 10)) { $version = dequote($1); if(not ($version =~ s/\$\{VERSION:-([^}]+)\}/$1/)) { log_warning("$file:$lineno: VERSION ignores environment, try VERSION=\${VERSION:-$version}"); } $version = dequote($1); if($version ne $info{VERSION}) { log_error("$file:$lineno: VERSION ($version) doesn't match VERSION in the .info file ($info{VERSION})"); } } elsif(/^BUILD=(\S+)/) { $build = dequote($1); if(not ($build =~ /\d/)) { log_error("$file:$lineno: BUILD is non-numeric"); } elsif(not ($build =~ /\$\{BUILD:-\d+}/)) { log_warning("$file:$lineno: BUILD ignores environment (try BUILD=\${BUILD:-$build}"); } } elsif(/^TAG=(\S+)/) { $tag = dequote($1); if($tag !~ /\$\{TAG:-(?:_SBo|("|')_SBo(\1))\}/) { log_error("$file:$lineno: TAG=\${TAG:-_SBo} is required"); } } elsif(/^TMP=(\S+)/) { $tmp = dequote($1); if($tmp !~ m,\$\{TMP:-(?:/tmp/SBo|(["'])/tmp/SBo\1)\},) { log_error("$file:$lineno: TMP=\${TMP:-/tmp/SBo} is required"); } } elsif(/^PKG=(\S+)/) { $pkg = dequote($1); $pkg =~ s,\$\{([A-Z]+)\},\$$1,; # de-brace if($pkg ne '$TMP/package-$PRGNAM' && $pkg ne '$TMP/package-${PRGNAM}') { log_error("$file:$lineno: PKG=\$TMP/package-\$PRGNAM is required"); } } elsif(/^OUTPUT=(\S+)/) { $output = dequote($1); if($output !~ m,\$\{OUTPUT:-(?:/tmp|(["'])/tmp\1)\},) { log_error("$file:$lineno: OUTPUT=\${OUTPUT:-/tmp} is required"); } } elsif(/^[^#]*\$\{?CWD\}?\/doinst\.sh(?:"|'|\s|>)/) { # 20220205 bkw: some scripts don't have a doinst.sh in the # script dir, but they create one with >> (the jack rt audio stuff # does this). $need_doinst = $lineno; } elsif(/^[^#]*\$\{?CWD\}?\/douninst\.sh(?:"|'|\s|>)/) { $need_douninst = $lineno; } elsif(/^[^#]*slack-desc/) { $slackdesc = $lineno; $install = $lineno if m,install/,; # assume OK } elsif(/^[^#]*?cd\s+[{\$"]*PKG[}"]*/) { $cdpkg = $lineno; } elsif(/^[^#]*?["{\$]+PKG[}"]*\/install/) { $install = $lineno; } elsif($cdpkg && /^[^#]*mkdir[^#]*install/) { $install = $lineno; } elsif(/^[^#]*makepkg/) { if(/\$\{PKGTYPE:-/) { log_error("$file:$lineno: makepkg PKGTYPE doesn't match 15.0 template"); } if($makepkg) { log_error("$file:$lineno: makepkg called twice (here and line $makepkg"); } $makepkg = $lineno; } elsif(/^\s*?CWD=/) { log_warning("$file:$lineno: lone CWD= assignment is redundant in 15.0 template"); } if(/^[^#]*/) { log_error("$file:$lineno: copy actual documentation, not "); } my $line = $_; if(grep { $line =~ /$_/ } @boilerplate) { log_warning("$file:$lineno: template comment should be removed"); } # special case here: don't complain about this comment if it's a perl-* build if($file !~ /^perl-/) { if($line =~ /#\s*Remove perllocal.pod and other special files/) { log_warning("$file:$lineno: template comment should be removed"); } } # 20220312 bkw: 15.0 template if(/^[^#]*\$.*PRINT_PACKAGE_NAME/) { $print_pkg_name = $lineno; } # 20230204 bkw: more 15.0 template if(/^PKGTYPE=/) { $pkg_type = $lineno; if(not /:-tgz/) { log_error("$file:$lineno: PKGTYPE doesn't default to tgz"); } } if((not defined $arch_lineno) && (/\bARCH:?=/)) { $arch_lineno = $lineno; } if(/^[^#]*\bARCH=['"]?i[34]86/) { $old_arch = $lineno; } if(/^[^#]*\s-march=['"]?i[34]86/) { $old_flags = $lineno; } if(/^\s*python\s/) { log_note("$file:$lineno: suggest replacing 'python' with 'python2' for future-proofing"); $have_py2 = $lineno; } if(/^\s*python2\s/) { $have_py2 = $lineno; } if(/^\s*python3\s/) { $have_py3 = $lineno; } if(/^[^#]*LIBDIRSUFFIX=/) { $libsuf_set = $lineno; } if(/^[^#]*SLKCFLAGS=/) { $flags_set = $lineno; } if(/^[^#]*\$\{?LIBDIRSUFFIX/) { $libsuf_used = $lineno; } if(/^[^#]*\$\{?SLKCFLAGS/) { $flags_used = $lineno; } } if(not defined($prgnam)) { log_error("$file: no PRGNAM= line"); } if(not defined($version)) { log_error("$file: no VERSION= line"); } if(not defined($build)) { log_error("$file: no BUILD= line"); } if(not defined($tag)) { log_error("$file: no TAG= line"); } if(not defined($tmp)) { log_error("$file: no TMP= line"); } if(not defined($pkg)) { log_error("$file: no PKG= line"); } if(not defined($output)) { log_error("$file: no OUTPUT= line"); } if(not defined($slackdesc)) { log_error("$file: doesn't seem to install slack-desc in \$PKG/install"); } if(not defined($makepkg)) { log_error("$file: no makepkg command found"); } # 20230204 bkw: better support for 15.0 template. if(defined($print_pkg_name)) { if((!defined $pkg_type) || ($pkg_type > $print_pkg_name)) { log_error("$file: PKGTYPE not defined before PRINT_PACKAGE_NAME stanza (Slackware >= 15.0)"); } if((!defined $arch_lineno) || ($arch_lineno > $print_pkg_name)) { log_error("$file: ARCH not defined before PRINT_PACKAGE_NAME stanza (Slackware >= 15.0)"); } } else { log_error("$file: missing PRINT_PACKAGE_NAME stanza (Slackware >= 15.0)"); } if(not defined($install)) { log_error("$file: nothing gets installed in \$PKG/install"); } my $have_doinst = (-f "doinst.sh"); my $have_douninst = (-f "douninst.sh"); if($have_doinst) { check_and_read("doinst.sh", 0644); } if($have_douninst) { check_and_read("douninst.sh", 0644); } if($need_doinst && !$have_doinst) { log_error("$file:$need_doinst: script installs doinst.sh, but it doesn't exist"); } elsif($have_doinst && !$need_doinst) { log_error("$file: doinst.sh exists, but the script doesn't install it"); } if($need_douninst && !$have_douninst) { log_error("$file:$need_douninst: script installs douninst.sh, but it doesn't exist"); } elsif($have_douninst && !$need_douninst) { log_error("$file: douninst.sh exists, but the script doesn't install it"); } if($old_arch) { # checking for the flags is supposed to let us tell the difference between # source builds and binary repacks. since we have 30 or so old binary repack # builds that use ARCH=i386 or ARCH=i486, we make it a warning, not an error. if($old_flags) { log_error("$file:$old_arch: compiling for i386 or i486 is no longer supported"); } else { log_warning("$file:$old_arch: outdated ARCH, please update to i586"); } } elsif($old_flags) { log_error("$file:$old_flags: archaic -march= compiler flag (we support i586 and up)"); } if($have_py2 && $have_py3) { log_note("$file: it looks like this build includes both python2 and python3 code. consider splitting it into separate python2- and python3- builds."); } # SLKCFLAGS and LIBDIRSUFFIX are notes, not errs/warns, because there are # over 2000 existing builds from before this was added. anyway, unused # variables don't really hurt anything. if($flags_set && (!$flags_used)) { log_note("$file:$flags_set: SLKCFLAGS gets set, but never used."); } if($libsuf_set && (!$libsuf_used)) { log_note("$file:$libsuf_set: LIBDIRSUFFIX gets set, but never used."); } } sub check_doinst { my $file = "doinst.sh"; return unless -f $file; my @lines = check_and_read($file); return unless scalar @lines; my $lineno = 0; my ($config_defined, $icon_theme_check, $pp_defined); for(@lines) { $lineno++; s,#.*,,; s,^\s*,,; s,\s*$,,; next unless /./; if(/^config\(\)/) { $config_defined = $lineno; } elsif(/^config\s+/) { # the [\@\$] is intended to skip stuff like $NEW, or a for loop variable, # or something intended to be sedded like @PATH@. if(/^config\s+\//) { log_error("$file:$lineno: 'config' uses absolute path."); } if(!/[\@\$]/ && !/\.new["']?$/) { log_error("$file:$lineno: 'config' filename missing .new suffix."); } if(!$config_defined) { log_error("$file:$lineno: 'config' function used, but not defined."); } } elsif(/^preserve_perms\(\)/) { $pp_defined = $lineno; } elsif(/^preserve_perms\s+/) { if(/^preserve_perms\s+\//) { log_error("$file:$lineno: 'preserve_perms' uses absolute path."); } if(!/[\@\$]/ && !/\.new["']?$/) { log_error("$file:$lineno: 'preserve_perms' filename missing .new suffix."); } if(!$pp_defined) { log_error("$file:$lineno: 'preserve_perms' function used, but not defined."); } } elsif(m,-e\s+(\/)?usr/share/icons/[^/]*/icon-theme\.cache,) { if(defined $1) { log_error("$file:$lineno: bad icon cache check (/usr/, should be usr/)"); } $icon_theme_check = $lineno; } elsif(m,usr/bin/gtk-update-icon-cache.*usr/share/icons,) { if(!$icon_theme_check) { log_error("$file:$lineno: icon cache created unconditionally!"); } undef $icon_theme_check; # in case there's multiple icon dirs } } } sub findem { my ($findcmd, $errmsg) = @_; open my $fh, "-|", "$findcmd"; while(<$fh>) { chomp; s,^\./,,; log_error("$errmsg: $_"); } close $fh; } # empty/hidden files/dirs. sub check_empty_hidden { findem("find . -type d -a -empty", "empty directory"); findem("find . -type f -a -empty", "empty file"); findem("find . -type d -a -name '.*' -a ! -name . -a ! -name ..", "hidden directory"); findem("find . -type f -a -name '.*'", "hidden file"); } # stuff like editor backups and dangling symlinks. # maybe *any* symlinks? # ELF objects are bad, too. # Big-ass files... # directories are OK, but hidden dirs are not. # note that this check only gets called when checking a tarball (not a dir). sub check_junkfiles { my @sources = split(/\s+/, $info{DOWNLOAD} . " " . $info{DOWNLOAD_x86_64}); s,.*/,, for @sources; @sources = grep { $_ !~ /^(?:\s*|UNTESTED|UNSUPPORTED)$/ } @sources; if(!grep { $_ =~ /^v$info{VERSION}\./ } @sources) { push @sources, "v$info{VERSION}.$_" for qw /zip tar.gz tar.bz2 tar.xz/; } open my $fh, "-|", "find . ! -type d -print0 | xargs -0 file --mime-type"; FILE: while(<$fh>) { chomp; my ($file, $type) = split /: */, $_, 2; $file =~ s,^\./,,; # skip the files caught by other checks next if $file eq "$buildname.SlackBuild"; next if $file eq "$buildname.info"; next if $file eq "README"; next if $file eq "slack-desc"; next if $file =~ /(?:diff|patch)$/; check_mode($file, 0644); if(grep { $_ eq $file } @sources) { log_error("source archive found: $file"); next FILE; } for($file) { (/\.swp\w*$/ || /#/ || /~/ ) && do { log_error("editor backup found: $file"); next FILE; }; /^\./ && do { log_error("hidden file found: $file"); next FILE; }; /\.(?:orig|bak|old)[^.]*$/ && do { log_warning("$file looks like sort some of backup file"); next FILE; }; /^(?:build.log|strace.out)/ && do { log_warning("$file is a build log"); next FILE; }; /\.rej$/ && do { log_warning("$file is a patch reject"); next FILE; }; /\.desktop$/ && do { system("desktop-file-validate $file"); if($? != 0) { log_warning("$file fails desktop-file-validate"); next FILE; } } } for($type) { ($_ eq "inode/x-empty") && do { log_error("$file is empty (0 bytes long)"); next FILE; }; ($_ =~ /^inode/) && do { log_error("$file is $type, not a regular file or directory"); next FILE; }; ($_ =~ m,application/x-(?:executable|dosexec|object|coredump),) && do { log_error("$file is object code ($type)"); next FILE; }; } my $size = -s $file; if($size > 1024 * 100) { log_warning("$file is large ($size bytes), may be rejected by submission form"); } } close $fh; open $fh, "-|", "find . -type d -mindepth 1"; while(<$fh>) { chomp; s,\./,,; if(/^\./) { log_error("found hidden directory: $_"); next; } if(glob("$_/*.o")) { log_error("$_ contains compiled object files (leftover source tree?)"); next; } for my $badfile (qw/Makefile configure CmakeLists.txt makefile.pl SConstruct/) { if(-f "$_/$badfile") { log_error("$_ looks like extracted source tree (contains $badfile)"); } } } close $fh; # # this won't always catch everything (e.g. PRGNAM=foo VERSION=1, but the # # extracted dir is foo1 or foo_1 or foo-source-1). # if(-d "$buildname-$version") { # log_warning("$buildname-$version/ looks like extracted source dir"); # } } # if anything *.diff or *.patch contains \r, warn the # user about git stripping the \r's (better gzip it). sub check_patches { for(<*.diff>,<*.patch>) { check_and_read($_, 0644); } } # checking an image is a bit of a PITA. "file" can tell us if it's # not an image, or has the wrong extension. # ImageMagick's "identify" command won't detect truncated images. # "convert" will, but it always returns 0/success, so we have to # parse its output. sub im_check_img { our %ext2mime; my $mime; my $ok = 1; %ext2mime = ( png => 'image/png', jpg => 'image/jpeg', xpm => 'image/x-xpm', gif => 'image/gif', ) unless %ext2mime; my $img = shift; my $ext = $img; $ext =~ s,.*\.,,; $ext = lc $ext; chomp($mime = `file -L --brief --mime "$img"`); if($mime !~ /$ext2mime{$ext}/) { log_error("$img has wrong extension $ext (MIME type is $mime)"); return; } open my $im, "convert \"$img\" png:/dev/null 2>&1 |"; while(<$im>) { $ok = 0 if /premature|corrupt/i; } close $im; log_error("$img appears to be corrupt") unless $ok; } sub check_images { my $images = `find . \\( -iname '*.jpg' -o -iname '*.png' -o -iname '*.xpm' -o -iname '*.gif' \\) -print0`; for(split /\x00/, $images) { check_mode($_, 0644); im_check_img($_); } }