#!/usr/bin/perl -w

# Don't edit the next line; use "make version" instead.
$VERSION="0.9.5";

=pod

=head1 NAME

sbolint - check SlackBuild directories or tarballs for common errors.

=head1 SYNOPSIS

B<sbolint> [--help | --man | --version ] | [-a] [-q] [-u] [-e] [-n] [-r] [-c] [-m] [build [build ...]]

=head1 DESCRIPTION

B<sbolint> 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<sbolint> 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<sbolint> shares no code with the upload form's submission checker. Lack
of errors/warnings from B<sbolint> does not guarantee that your build
will be accepted!

B<sbolint> doesn't check built packages, and never executes the build
script. If you want a lint tool for binary Slackware packages, use
B<sbopkglint>.

Note that B<sbolint> 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<urchlay@slackware.uk>) and explain the
situation. Most likely, B<sbolint> 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<sbolint
-a> will redirect stdout to a file called B<sbolint.log.>I<YYYYMMDD>
(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<curl> to make HTTP HEAD requests for the B<HOMEPAGE>,
B<DOWNLOAD>, and B<DOWNLOAD_x86_64> 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<-N>, B<--notes-as-warnings>

Notes will be counted as warnings. They will still be displayed as notes; this
changes the exit status and the total warning count.

=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<tar>(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<foo.tar.gz> must contain I<foo/>. Everything else in the archive must be
inside this directory. The directory's permissions must be 0755.

=item -

Archive must contain I<dirname/dirname.SlackBuild>.

=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<sbrun>
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<PRGNAM> in the script must match I<PRGNAM> in the .info file. Both must
match the script name (I<PRGNAM.SlackBuild>) and the directory name.

=item -

I<VERSION> must match the I<VERSION> in the .info file.

=item -

I<TAG=${TAG:-_SBo}>, I<TMP=${TMP:-/tmp/SBo}>, I<PKG=$TMP/package-$PRGNAM>, and
I<OUTPUT=${OUTPUT:-/tmp}> must occur in the script.

=item -

The I<VERSION> and I<BUILD> variables must respect the environment.

=item -

The script must contain the I<PRINT_PACKAGE_NAME> section.

=item -

The script must assign I<ARCH> and I<PKGTYPE> before the I<PRINT_PACKAGE_NAME> 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<makepkg> 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 -

The script must exit with failure status if the build fails. Normally this
is done with B<set -e> somewhere near the top of the script. Also allowed
is B<#!/bin/bash -e> (or B<-ue, -eu, etc>) on the first line, or manual
checks with B<|| exit 1>. If none of these things are found, it's an error.

=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<ftp://>, B<http://>, or B<https://>).

=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<gtk-update-icon-cache>, the existence of the
cache must be checked before this command is run.

=back

=item -

Image files are checked for permissions (0644), filename (extension
must match filetype, e.g. a GIF image must not be named image.jpg),
and correctness (must not be truncated/corrupt).

=item -

Patch files (anything named *.diff or *.patch) are checked for
permissions (0644) and DOS/Windows CRLF line endings. If a patch
file has CRLF endings, it should not be checked into SBo's repository,
because git may strip the CR (\r) from every line, meaning the patch
would fail to apply. In this case, the best solution is to gzip the
patch, and have the SlackBuild use e.g. "zcat $CWD/blah.diff.gz |
patch -p1" to apply it.

Note that the line-ending problem only triggers a note, not a warning
or error. The trouble with git stripping \r's has become mostly a
thing of the past in recent versions of git. It's still possible for
this to happen if e.g. you're for some reason editing a SlackBuild on
a Windows machine (but why would you do that?)

=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 <urchlay@slackware.uk>, aka Urchlay on Libera IRC.

=head1 SEE ALSO

B<sbopkglint>(1), B<sbodl>, B<sbofixinfo>(1), B<sbopkg>(8), B<sboinstall>(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,
		"notes-as-warnings|N" => \$notes_as_warnings,
		"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 = <STDIN>;
	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, @_);
	$warncount++ if $notes_as_warnings;
}

sub die_usage {
	die "$SELF: $_[0] (try --help)\n"
}

sub chdir_or_die {
	chdir($_[0]) or die "$SELF: chdir($_[0]): $!\n";
}

sub get_mode {
	07777 & ((stat($_[0]))[2]);
}

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");

	# 20260602 bkw: check permissions on the enclosing dir. If it's
	# not +x, the upload form will fail.
	my $gotmode = get_mode("$tempdir/$buildname");
	if($gotmode != 0755) {
		my $p = sprintf("%04o", $gotmode);
		log_error("$file not a SBo-compliant tarball, permission of $buildname/ should be 0755, not $p.");
	}

	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? 2 choices:
	# in a git repo, it has to be 644.
	# anywhere else, 644 and 755 are allowed.
	# 20260603 bkw: this changed. we used to enforce 755 in tarballs, but
	# there's no point in it: we don't use the submission tarball as the
	# final downloadable file on the site. everything goes through git,
	# with a git hook that changes the script perms to 0644... then when
	# the update happens and the site tarballs get generated, the script
	# that does that, sets the script perms to 0755. so it really doesn't
	# matter what they are in the submission tarball.
	if($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_patches,
				\&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;

		push @checks, \&not_in_git if $in_git_repo;

		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 = get_mode($file);
	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");
		}
	}

	if($file =~ /\.(diff|patch)$/i) {
		log_note("$file has DOS-style CRLF line endings, git may mess it up unless you gzip it") if $crlf_err;
		# 20260523 bkw: do not complain if patches lack EOF at end!
	} else {
		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_empty_lines {
	my $empties = 0;

	while(defined($_[0])) {
		if($_[0] =~ /^\s*$/) {
			$empties++;
		} else {
			last;
		}
		shift;
	}

	return $empties;
}

sub check_leading_empty_lines {
	my $file = shift;
	my $count = check_empty_lines(@_);
	return unless $count;
	log_error("$file begins with $count blank line" . ($count == 1 ? "" : "s"));
}

sub check_trailing_empty_lines {
	my $file = shift;
	my $count = check_empty_lines(reverse @_);
	return unless $count;
	my $msg = "$file ends with $count blank line";
	if($count == 1) {
		log_note($msg);
	} else {
		log_error($msg . "s");
	}
}

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");
	}

	check_leading_empty_lines('README', @lines);
	check_trailing_empty_lines('README', @lines);

	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(/\t/) {
			log_error("$file:$lineno: tab characters are not allowed");
		}

		if(/\r/) {
			log_error("$file:$lineno: carriage returns (\\r) are not allowed");
		}

		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 60 --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);
}

# github is a mess...
# 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.
# If there's a "v" and it's immediately followed by a number, it's not part of
# the version number. If it's followed by something other than a number, e.g.
# ver-1.0 or v.1.0, it *is* part of the version number.
# Since git allows / characters in tag names, we sometimes get a URL like:
# https://github.com/jeremysalwen/lv2file/archive/upstream/0.95/lv2file-upstream-0.95.tar.gz
# ...which is perfectly valid. However, this is invalid:
# https://github.com/jeremysalwen/lv2file/archive/upstream/0.95.tar.gz

sub check_github_url {
	my $file = shift;
	my $url = shift;
	my $new_url;
	my $ext;
	my $tag;
	my $ver;
	my $dir;

	# do not police releases/ or raw/ URLs, only archive/
	# 20241008 bkw: also only police URLs that are in the main github.com
	# domain, not e.g. raw.github.com or gists.github.com.
	return unless $url =~ m{//github\.com/[^/]*/[^/]*/archive/};

	# allow *and ignore* refs/tags
	$url =~ s,refs/tags/,,;

	    #https:  //    site/  .../   .../  archive/  ...everything else.
	(my $proto, undef, undef, $user, $proj, undef,   @parts) = split /\//, $url;

	log_error "$file: github URLs must be https://" unless $proto eq 'https:';

	my $filename = pop @parts;
	$filename =~ m,(\.tar\.gz|\.zip)$,;
	$ext = $1;

	if(!defined $ext) {
		log_warning("$file: github URL should end in .tar.gz or .zip");
		$ext = "";
	}

	if(@parts == 0) {
		# filename loox like: tag.tar.gz
		($tag = $filename) =~ s,(\.tar\.gz|\.zip)$,,;
		log_error "$file: github URL is non-canonical (not enough components)";
		$dir = $tag;
	} elsif(@parts == 1) {
		$tag = $dir = $parts[0];
		if($tag =~ /^[0-9a-f]{7,}$/ && $tag !~ /^20\d{6,}/) {
			# commit hash. the /^20\d{6,}/ is to exclude ISO dates like 20240402
			if(length($tag) < 40) {
				# shortened, maybe the full hash is in the filename?
				if($filename =~ /([0-9a-f]{40})\./) {
					$tag = $1; # leave $dir alone! it's allowed to be shortened.
				} else {
					# if not, we give up.
					log_error("$file: github commit URL needs full 40-digit commit hash in filename");
					return;
				}
			}
		}
	} else {
		$tag = join("-", @parts);
		$dir = join("/", @parts);
	}

	($ver = $tag) =~ s,^v(\d),$1,i;

	# TODO: commit hashes have to be complete in the filename, and may be
	# truncated in the dir name after archive/

	if(defined $filename && $filename ne "$proj-$ver$ext") {
		log_error "$file: github URL has wrong filename $filename";
	}

	$new_url = "https://github.com/$user/$proj/archive/$dir/$proj-$ver$ext";

	if($url ne $new_url) {
		log_warning("$file: canonical github URL should be: $new_url");
	}
}

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 <documentation> is an error

sub check_script {
	my $file = $buildname . ".SlackBuild";

	my $gotmode = get_mode($file);
	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;

	my $bash_e_opt;
	if($lines[0] !~ /^#!/) {
		log_error("$file:1: missing or invalid shebang line (should be '#!/bin/bash')");
	} elsif($lines[0] =~ m,#!/bin/bash(?:\s+(-e|-eu|-ue|-e -u|-u -e))?\s*$,) {
		my $arg = $1;
		$bash_e_opt = 1 if defined($arg) && $arg =~ /e/;
	} else {
		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);
	my ($set_e, $or_exit_1);
	$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");
		} elsif(/^\s*?chown\s/) {
			if(!chown_ok($_)) {
				log_warning("$file:$lineno: chown should use : instead of . for user:group separator");
			}
		}

		if(/^[^#]*<documentation>/) {
			log_error("$file:$lineno: copy actual documentation, not <documentation>");
		}

		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;
		}

		# 20240402 bkw: we have a few scripts that do things like "ARCH=i386 make",
		# this line should only detect bare assignments or "case".
		if(/^[^#]*\bARCH=['"]?i[34]86['"]?[;\s]*$/) {
			$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(/^[^#]*set\s+([^#]+)/) {
			my $arg = $1;
			$set_e = $lineno if $arg =~ /e/;
		}

		if(/^[^#]*\|\|\s+exit\s+1/) {
			$or_exit_1 = $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.");
	}

	## warn "\$set_e: " . ($set_e || 0);
	## warn "\$bash_e_opt: " . ($bash_e_opt || 0);
	## warn "\$or_exit_1: " . ($or_exit_1 || 0);

	if(!(defined $set_e || defined $bash_e_opt || defined $or_exit_1)) {
		log_error("$file: this script has no error checking (need bash -e option, 'set -e', or '|| exit 1').");
	}
}

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 chown_ok {
	my $cmd = shift;
	$cmd =~ s/#.*//; # get rid of comment, if any
	$cmd =~ s/^\s+//; # get rid of leading whitespace, if any

	# assign 1st element to undef, to get rid of the chown command itself
	my (undef, @args) = split /\s+/, $cmd;

	# what remains should include user:group or user.group. if it doesn't,
	# don't complain: possibly the --reference option is being used.
	# dots are OK in file paths (e.g. chown root:root /etc/blah.d/blah).
	# we can't easily tell which is which, but *something* should have a colon.
	my $dot;
	for my $arg (@args) {
		return 1 if /:/;   # found :, we're OK.
		next     if /\//;  # if there's a /, it's a filename (dots are OK).
		$dot++   if /\./;  # found a dot, might be not OK.
	}

	# if we get here, there are no colons. if there *is* a dot, return failure.
	return !$dot;
}

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");
#	}
}

# 20250114 bkw: if we're in a git repo and these files exist, but are
# not checked in to git, that generally means someone forgot to 'git
# add' them.
sub not_in_git {
	open my $gitpipe, "git status --porcelain=2 . |";
	if(!$gitpipe) {
		log_error("can't run 'git status' but we're in a git repo");
		return;
	}

	while(<$gitpipe>) {
		chomp;

		my ($file) = (/^\?\s(.*)/);
		next unless $file;

		for($file) {
			my $msg = "$file not in git repo, did you mean to add it?";
			if(/^.*\.(?:diff|patch|sh|desktop)/) {
				log_warning($msg);
			} else {
				log_note($msg);
			}
		}
	}
}

# if anything *.diff or *.patch contains \r, warn the
# user about git stripping the \r's (better gzip it).
# 20231223 bkw: this check relies on check_and_read special-casing
# filenames ending in *.diff or *.patch.
sub check_patches {
	my $patches = `find . \\( -iname '*.diff' -o -iname '*.patch' \\) -print0`;
	for(split /\x00/, $patches) {
		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($_);
	}
}
