#!/allftp/bin/perl -w
#
# debianqueued -- daemon for managing Debian upload queues
#
# Copyright (C) 1997 Roman Hodek <Roman.Hodek@informatik.uni-erlangen.de>
#
# This program is free software.  You can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation: either version 2 or
# (at your option) any later version.
# This program comes with ABSOLUTELY NO WARRANTY!
#
# $Id: debianqueued,v 1.47 1998/05/14 14:21:44 ftplinux Exp $
#
# $Log: debianqueued,v $
# Revision 1.47  1998/05/14 14:21:44  ftplinux
# Bumped release number to 0.8
#
# Revision 1.46  1998/05/14 14:17:00  ftplinux
# When --after a successfull upload-- deleting files for the same job, check
# for equal revision number on files that have one. It has happened that the
# daemon deleted files that belonged to another job with different revision.
#
# Revision 1.45  1998/04/23 11:05:47  ftplinux
# Implemented $conf::chmod_on_master. If 0, new part to change mode locally in
# process_changes.
#
# Revision 1.44  1998/04/21 08:44:44  ftplinux
# Don't use return value of debian_file_stem as regexp, it's a shell pattern.
#
# Revision 1.43  1998/04/21 08:22:21  ftplinux
# Also recogize "read-only filesystem" as error message so it triggers assuming
# that incoming is unwritable.
# Don't increment failure count after an upload try that did clear
# $incoming_writable.
# Fill in forgotten pattern for mail addr in process_commands.
#
# Revision 1.42  1998/03/31 13:27:32  ftplinux
# In fatal_signal, kill status daemon only if it has been started (otherwise
# warning about uninitialized variable).
# Change mode of files uploaded to master explicitly to 644 there, scp copies the
# permissions in the queue.
#
# Revision 1.41  1998/03/31 09:06:00  ftplinux
# Implemented handling of improper mail addresses in Maintainer: field.
#
# Revision 1.40  1998/03/24 13:17:33  ftplinux
# Added new check if incoming dir on master is writable. This check is triggered
# if an upload returns "permission denied" errors. If the dir is unwritable, the
# queue is holded (no upload tries) until it's writable again.
#
# Revision 1.39  1998/03/23 14:05:14  ftplinux
# Bumped release number to 0.7
#
# Revision 1.38  1998/03/23 14:03:55  ftplinux
# In an upload failure message, say explicitly that the job will be
# retried, to avoid confusion of users.
# $failure_file was put on@keep_list only for first retry.
# If the daemon removes a .changes, set SGID bit on all files associated
# with it, so that the test for Debian files without a .changes doesn't
# find them.
# Don't send reports for files without a .changes if the files look like
# a recompilation for another architecture.
# Also don't send such a report if the list of files with the same stem
# contains a .changes.
# Set @keep_list earlier, before PGP and non-US checks.
# Fix recognition of -k argument.
#
# Revision 1.37  1998/02/17 12:29:58  ftplinux
# Removed @conf::test_binaries used only once warning
# Try to kill old daemon for 20secs instead of 10
#
# Revision 1.36  1998/02/17 10:53:47  ftplinux
# Added test for binaries on maybe-slow NFS filesystems (@conf::test_binaries)
#
# Revision 1.35  1997/12/16 13:19:28  ftplinux
# Bumped release number to 0.6
#
# Revision 1.34  1997/12/09 13:51:24  ftplinux
# Implemented rejecting of nonus packages (new config var @nonus_packages)
#
# Revision 1.33  1997/11/25 10:40:53  ftplinux
# In check_alive, loop up the IP address everytime, since it can change
# while the daemon is running.
# process_changes: Check presence of .changes on master at a later
# point, to avoid bothering master as long as there are errors in a
# .changes.
# Don't view .orig.tar.gz files as is_debian_file, to avoid that they're
# picked for extracting the maintainer address in the
# job-without-changes processing.
# END statement: Fix swapped arguments to kill
# Program startup: Implemented -r and -k arguments.
#
# Revision 1.32  1997/11/20 15:18:47  ftplinux
# Bumped release number to 0.5
#
# Revision 1.31  1997/11/11 13:37:52  ftplinux
# Replaced <./$pattern> contruct be cleaner glob() call
# Avoid potentially uninitialized $_ in process_commands file read loop
# Implemented rm command with more than 1 arg and wildcards in rm args
#
# Revision 1.30  1997/11/06 14:09:53  ftplinux
# In process_commands, also recognize commands given on the same line as
# the Commands: keyword, not only the continuation lines.
#
# Revision 1.29  1997/11/03 15:52:20  ftplinux
# After reopening the log file write one line to it for dqueued-watcher.
#
# Revision 1.28  1997/10/30 15:37:23  ftplinux
# Removed some leftover comments in process_commands.
# Changed pgp_check so that it returns the address of the signator.
# process_commands now also logs PGP signator, since Uploader: address
# can be choosen freely by uploader.
#
# Revision 1.27  1997/10/30 14:05:37  ftplinux
# Added "command" to log string for command file uploader, to make it
# unique for dqueued-watcher.
#
# Revision 1.26  1997/10/30 14:01:05  ftplinux
# Implemented .commands files
#
# Revision 1.25  1997/10/30 13:05:29  ftplinux
# Removed date from status version info (too long)
#
# Revision 1.24  1997/10/30 13:04:02  ftplinux
# Print revision, version, and date in status data
#
# Revision 1.23  1997/10/30 12:56:01  ftplinux
# Implemented deletion of files that (probably) belong to an upload, but
# weren't listed in the .changes.
#
# Revision 1.22  1997/10/30 12:22:32  ftplinux
# When setting sgid bit for stray files without a .changes, check for
# files deleted in the meantime.
#
# Revision 1.21  1997/10/30 11:32:19  ftplinux
# Added quotes where filenames are used on sh command lines, in case
# they contain metacharacters.
# print_time now always print three-field times, as omitting the hour if
# 0 could cause confusing (hour or seconds missing?).
# Implemented warning mails for incomplete uploads that miss a .changes
# file. Maintainer address can be extracted from *.deb, *.diff.gz,
# *.dsc, or *.tar.gz files with help of new utility functions
# is_debian_file, get_maintainer, and debian_file_stem.
#
# Revision 1.20  1997/10/13 09:12:21  ftplinux
# On some .changes errors (missing/bad PGP signature, no files) also log the
# uploader
#
# Revision 1.19  1997/09/25 11:20:42  ftplinux
# Bumped release number to 0.4
#
# Revision 1.18  1997/09/25 08:15:02  ftplinux
# In process_changes, initialize some vars to avoid warnings
# If first consistency checks failed, don't forget to delete .changes file
#
# Revision 1.17  1997/09/16 10:53:35  ftplinux
# Made logging more verbose in queued and dqueued-watcher
#
# Revision 1.16  1997/08/12 09:54:39  ftplinux
# Bumped release number
#
# Revision 1.15  1997/08/11 12:49:09  ftplinux
# Implemented logfile rotating
#
# Revision 1.14  1997/08/11 11:35:05  ftplinux
# Revised startup scheme so it works with the socket-based ssh-agent, too.
# That watches whether its child still exists, so the go-to-background fork must be done before the ssh-agent.
#
# Revision 1.13  1997/08/11 08:48:31  ftplinux
# Aaarg... forgot the alarm(0)'s
#
# Revision 1.12  1997/08/07 09:25:22  ftplinux
# Added timeout for remote operations
#
# Revision 1.11  1997/07/28 13:20:38  ftplinux
# Added release numner to startup message
#
# Revision 1.10  1997/07/28 11:23:39  ftplinux
# $main::statusd_pid not necessarily defined in status daemon -- rewrite check
# whether to delete pid file in signal handler.
#
# Revision 1.9  1997/07/28 08:12:16  ftplinux
# Again revised SIGCHLD handling.
# Set $SHELL to /bin/sh explicitly before starting ssh-agent.
# Again raise ping timeout.
#
# Revision 1.8  1997/07/25 10:23:03  ftplinux
# Made SIGCHLD handling more portable between perl versions
#
# Revision 1.7  1997/07/09 10:15:16  ftplinux
# Change RCS Header: to Id:
#
# Revision 1.6  1997/07/09 10:13:53  ftplinux
# Alternative implementation of status file as plain file (not FIFO), because
# standard wu-ftpd doesn't allow retrieval of non-regular files. New config
# option $statusdelay for this.
#
# Revision 1.5  1997/07/09 09:21:22  ftplinux
# Little revisions to signal handling; status daemon should ignore SIGPIPE,
# in case someone closes the FIFO before completely reading it; in fatal_signal,
# only the main daemon should remove the pid file.
#
# Revision 1.4  1997/07/08 11:31:51  ftplinux
# Print messages of ssh call in is_on_master to debug log.
# In ssh call to remove bad files on master, the split() doesn't work
#   anymore, now that I use -o'xxx y'. Use string interpolation and let
#   the shell parse the stuff.
#
# Revision 1.3  1997/07/07 09:29:30  ftplinux
# Call check_alive also if master hasn't been pinged for 8 hours.
#
# Revision 1.2  1997/07/03 13:06:49  ftplinux
# Little last changes before beta release
#
# Revision 1.1.1.1  1997/07/03 12:54:59  ftplinux
# Import initial sources
#
#

require 5.002;
use strict;
use POSIX;
use POSIX qw( sys_stat_h sys_wait_h signal_h );
use Net::Ping;
use Socket qw( PF_INET AF_INET SOCK_STREAM );
use Config;

# ---------------------------------------------------------------------------
#								configuration
# ---------------------------------------------------------------------------

package conf;
($conf::queued_dir = (($0 !~ m,^/,) ? POSIX::getcwd()."/" : "") . $0)
	=~ s,/[^/]+$,,;
require "$conf::queued_dir/config";
my $junk = $conf::debug; # avoid spurious warnings about unused vars
$junk = $conf::ssh_key_file;
$junk = $conf::stray_remove_timeout;
$junk = $conf::problem_report_timeout;
$junk = $conf::queue_delay;
$junk = $conf::keep_files;
$junk = $conf::max_upload_retries;
$junk = $conf::upload_delay_1;
$junk = $conf::upload_delay_2;
$junk = $conf::ar;
$junk = $conf::gzip;
$junk = $conf::no_changes_timeout;
$junk = @conf::nonus_packages;
$junk = @conf::test_binaries;
$junk = @conf::maintainer_mail;
package main;

($main::progname = $0) =~ s,.*/,,;

# extract -r and -k args
$main::arg = "";
if (@ARGV == 1 && $ARGV[0] =~ /^-[rk]$/) {
	$main::arg = ($ARGV[0] eq '-k') ? "kill" : "restart";
	shift @ARGV;
}

# test for another instance of the queued already running
my $pid;
if (open( PIDFILE, "<$conf::pidfile" )) {
	chomp( $pid = <PIDFILE> );
	close( PIDFILE );
	if (!$pid) {
		# remove stale pid file
		unlink( $conf::pidfile );
	}
	elsif ($main::arg) {
		local($|) = 1;
		print "Killing running daemon (pid $pid) ...";
		kill( 15, $pid );
		my $cnt = 20;
		while( kill( 0, $pid ) && $cnt-- > 0 ) {
			sleep 1;
			print ".";
		}
		if (kill( 0, $pid )) {
			print " failed!\nProcess $pid still running.\n";
			exit 1;
		}
		print "ok\n";
		if (-e "$conf::incoming/core") {
			unlink( "$conf::incoming/core" );
			print "(Removed core file)\n";
		}
		exit 0 if $main::arg eq "kill";
	}
	else {
		die "Another $main::progname is already running (pid $pid)\n"
			if $pid && kill( 0, $pid );
	}
}
elsif ($main::arg eq "kill") {
	die "No daemon running\n";
}
elsif ($main::arg eq "restart") {
	print "(No daemon running; starting anyway)\n";
}

# if started without arguments (initial invocation), then fork
if (!@ARGV) {
	# now go to background
	die "$main::progname: fork failed: $!\n" unless defined( $pid = fork );
	if ($pid) {
		# parent: wait for signal from child (SIGCHLD or SIGUSR1) and exit
		my $sigset = POSIX::SigSet->new();
		$sigset->emptyset();
		$SIG{"CHLD"} = sub { };
		$SIG{"USR1"} = sub { };
		POSIX::sigsuspend( $sigset );
		waitpid( $pid, WNOHANG );
		if (kill( 0, $pid )) {
			print "Daemon started in background (pid $pid)\n";
			exit 0;
		}
		else {
			exit 1;
		}
	}
	else {
		# child: exec an ssh-agent that starts us again
		
		# force shell to be /bin/sh, ssh-agent may base its decision whether
		# to use a fd or a Unix socket on the shell...
		$ENV{"SHELL"} = "/bin/sh";
		exec $conf::ssh_agent, $0, "startup", getppid();
		die "$main::progname: Could not exec $conf::ssh_agent: $!\n";
	}
}
die "Please start without any arguments.\n"
	if @ARGV != 2 || $ARGV[0] ne "startup";
my $parent_pid = $ARGV[1];

do {
	my $version;
	($version = 'Release: 0.8 $Revision: 1.47 $ $Date: 1998/05/14 14:21:44 $ $Author: ftplinux $') =~ s/\$ ?//g;
	print "debianqueued $version\n";
};

# check if all programs exist
my $prg;
foreach $prg ( $conf::pgp, $conf::ssh, $conf::scp, $conf::ssh_agent,
			   $conf::ssh_add, $conf::md5sum, $conf::mail, $conf::mkfifo ) {
	die "Required program $prg doesn't exist or isn't executable\n"
		if ! -x $prg;
}

# ---------------------------------------------------------------------------
#							   initializations
# ---------------------------------------------------------------------------

# prototypes
sub check_dir();
sub process_changes($\@);
sub process_commands($);
sub is_on_master($);
sub copy_to_master(@);
sub pgp_check($);
sub check_alive(;$);
sub check_incoming_writable();
sub fork_statusd();
sub write_status_file();
sub print_status($$$$$$);
sub format_status_num(\$$);
sub format_status_str(\$$);
sub send_status();
sub rm(@);
sub md5sum($);
sub is_debian_file($);
sub get_maintainer($);
sub debian_file_stem($);
sub msg($@);
sub debug(@);
sub init_mail(;$);
sub finish_mail();
sub send_mail($$$);
sub try_to_get_mail_addr($$);
sub format_time();
sub print_time($);
sub block_signals();
sub unblock_signals();
sub close_log($);
sub kid_died($);
sub restart_statusd();
sub fatal_signal($);

$ENV{"PATH"} = "/bin:/usr/bin";
$ENV{"IFS"} = "" if defined($ENV{"IFS"} && $ENV{"IFS"} ne "");

# constants for stat
sub ST_DEV()   { 0 }
sub ST_INO()   { 1 }
sub ST_MODE()  { 2 }
sub ST_NLINK() { 3 }
sub ST_UID()   { 4 }
sub ST_GID()   { 5 }
sub ST_RDEV()  { 6 }
sub ST_SIZE()  { 7 }
sub ST_ATIME() { 8 }
sub ST_MTIME() { 9 }
sub ST_CTIME() { 10 }
# fixed lengths of data items passed over status pipe
sub STATNUM_LEN() { 30 }
sub STATSTR_LEN() { 128 }

# init list of signals
defined $Config{sig_name} or die "$main::progname: No signal list defined!\n";
my $i = 0;
my $name;
foreach $name (split( ' ', $Config{sig_name} )) {
	$main::signo{$name} = $i++;
}

@main::fatal_signals = qw( INT QUIT ILL TRAP ABRT BUS FPE USR2 SEGV PIPE
						   TERM XCPU XFSZ PWR );

$main::block_sigset = POSIX::SigSet->new;
$main::block_sigset->addset( $main::signo{"INT"} );
$main::block_sigset->addset( $main::signo{"TERM"} );

# some constant net stuff
$main::tcp_proto = (getprotobyname('tcp'))[2]
	or die "Cannot get protocol number for 'tcp'\n";
$main::echo_port = (getservbyname('ssh', 'tcp'))[2]
	or die "Cannot get port number for service 'ssh'\n";

# clear queue of stored mails
@main::stored_mails = ();

# run ssh-add to bring the key into the agent (will use stdin/stdout)
system "$conf::ssh_add $conf::ssh_key_file"
	and die "$main::progname: Running $conf::ssh_add failed ".
	        "(exit status ", $? >> 8, ")\n";

# change to queue dir
chdir( $conf::incoming )
	or die "$main::progname: cannot cd to $conf::incoming: $!\n";

# needed before /dev/null redirects, some system send a SIGHUP when loosing
# the controlling tty
$SIG{"HUP"} = "IGNORE";

# open logfile, make it unbuffered
open( LOG, ">>$conf::logfile" )
	or die "Cannot open my logfile $conf::logfile: $!\n";
chmod( 0644, $conf::logfile )
	or die "Cannot set modes of $conf::logfile: $!\n";
select( (select(LOG), $| = 1)[0] );

sleep( 1 );
$SIG{"HUP"} = \&close_log;

# redirect stdin, ... to /dev/null
open( STDIN, "</dev/null" )
	or die "$main::progname: Can't redirect stdin to /dev/null: $!\n";
open( STDOUT, ">&LOG" )
	or die "$main::progname: Can't redirect stdout to $conf::logfile: $!\n";
open( STDERR, ">&LOG" )
	or die "$main::progname: Can't redirect stderr to $conf::logfile: $!\n";
# ok, from this point usually no "die" anymore, stderr is gone!
msg( "log", "daemon (pid $$) started\n" );

# initialize variables used by send_status before launching the status daemon
$main::dstat = "i";
format_status_num( $main::next_run, time+10 );
format_status_str( $main::current_changes, "" );
check_alive();
$main::incoming_writable = 1; # assume this for now

# start the daemon watching the 'status' FIFO
if ($conf::statusfile && $conf::statusdelay == 0) {
	$main::statusd_pid = fork_statusd();
	$SIG{"CHLD"} = \&kid_died; # watch out for dead status daemon
	# SIGUSR1 triggers status info
	$SIG{"USR1"} = \&send_status;
}
$main::maind_pid = $$;

END { kill( $main::signo{"ABRT"}, $$ ) if defined $main::signo{"ABRT"}; }

# write the pid file
open( PIDFILE, ">$conf::pidfile" )
	or msg( "log", "Can't open $conf::pidfile: $!\n" );
printf PIDFILE "%5d\n", $$;
close( PIDFILE );
chmod( 0644, $conf::pidfile )
	or die "Cannot set modes of $conf::pidfile: $!\n";

# other signals will just log an error and exit
foreach ( @main::fatal_signals ) {
	$SIG{$_} = \&fatal_signal;
}

# send signal to user-started process that we're ready and it can exit
kill( $main::signo{"USR1"}, $parent_pid );

# ---------------------------------------------------------------------------
#								 the mainloop
# ---------------------------------------------------------------------------

$main::dstat = "i";
write_status_file() if $conf::statusdelay;
while( 1 ) {

	# ping master only if there is the possibility that we'll contact it (but
	# also don't wait too long).
	my @have_changes = <*.changes>;
	check_alive() if @have_changes || (time - $main::last_ping_time) > 8*60*60;

	if (@have_changes && $main::master_up) {
		check_incoming_writable if !$main::incoming_writable;
		check_dir() if $main::incoming_writable;
	}
	$main::dstat = "i";
	write_status_file() if $conf::statusdelay;

	# sleep() returns if we received a signal (SIGUSR1 for status FIFO), so
	# calculate the end time once and wait for it being reached.
	format_status_num( $main::next_run, time + $conf::queue_delay );
	my $delta;
	while( ($delta = calc_delta()) > 0 ) {
		debug( "mainloop sleeping $delta secs" );
		sleep( $delta );
		# check if statusd died, if using status FIFO, or update status file
		if ($conf::statusdelay) {
			write_status_file();
		}
		else {
			restart_statusd();
		}
	}
}

sub calc_delta() {
	my $delta;
	
	$delta = $main::next_run - time;
	$delta = $conf::statusdelay
		if $conf::statusdelay && $conf::statusdelay < $delta;
	return $delta;
}


# ---------------------------------------------------------------------------
#							main working functions
# ---------------------------------------------------------------------------


#
# main function for checking the incoming dir
#
sub check_dir() {
	my( @files, @changes, @keep_files, @this_keep_files, @stats, $file );
	
	debug( "starting checkdir" );
	$main::dstat = "c";
	write_status_file() if $conf::statusdelay;

	# test if needed binaries are available; this is if they're on maybe
	# slow-mounted NFS filesystems
	foreach (@conf::test_binaries) {
		next if -f $_;
		# maybe the mount succeeds now
		sleep 5;
		next if -f $_;
		msg( "log", "binary test failed for $_; delaying queue run\n");
		goto end_run;
	}
	
	# look for *.commands files
	foreach $file ( <*.commands> ) {
		init_mail( $file );
		block_signals();
		process_commands( $file );
		unblock_signals();
		$main::dstat = "c";
		write_status_file() if $conf::statusdelay;
		finish_mail();
	}
	
	opendir( INC, "." )
		or (msg( "log", "Cannot open incoming dir $conf::incoming: $!\n" ),
			return);
	@files = readdir( INC );
	closedir( INC );

	# process all .changes files found
	@changes = grep /\.changes$/, @files;
	push( @keep_files, @changes ); # .changes files aren't stray
	foreach $file ( @changes ) {
		init_mail( $file );
		# wrap in an eval to allow jumpbacks to here with die in case
		# of errors
		block_signals();
		eval { process_changes( $file, @this_keep_files ); };
		unblock_signals();
		msg( "log,mail", $@ ) if $@;
		$main::dstat = "c";
		write_status_file() if $conf::statusdelay;
		
		# files which are ok in conjunction with this .changes
		debug( "$file tells to keep @this_keep_files" );
		push( @keep_files, @this_keep_files );
		finish_mail();

		# break out of this loop if the incoming dir has become unwritable
		goto end_run if !$main::incoming_writable;
	}

	# find files which aren't related to any .changes
	foreach $file ( @files ) {
		# filter out files we never want to delete
		next if ! -f $file ||	# may have disappeared in the meantime
			    $file eq "." || $file eq ".." ||
			    (grep { $_ eq $file } @keep_files) ||
				$file =~ /$conf::keep_files/;
		# Delete such files if they're older than
		# $stray_remove_timeout; they could be part of an
		# yet-incomplete upload, with the .changes still missing.
		# Cannot send any notification, since owner unknown.
		next if !(@stats = stat( $file ));
		my $age = time - $stats[ST_MTIME];
		my( $maint, $pattern, @job_files );
		if ($age >= $conf::stray_remove_timeout) {
			msg( "log", "Deleted stray file $file\n" ) if rm( $file );
		}
		elsif ($age > $conf::no_changes_timeout &&
			   is_debian_file( $file ) &&
			   # not already reported
			   !($stats[ST_MODE] & S_ISGID) &&
			   ($pattern = debian_file_stem( $file )) &&
			   (@job_files = glob($pattern)) &&
			   # If a .changes is in the list, it has the same stem as the
			   # found file (probably a .orig.tar.gz). Don't report in this
			   # case.
			   !(grep( /\.changes$/, @job_files ))) {
			$maint = get_maintainer( $file );
			# Don't send a mail if this looks like the recompilation of a
			# package for a non-i386 arch. For those, the maintainer field is
			# useless :-(
			if (!grep( /(\.dsc|_(i386|all)\.deb)$/, @job_files )) {
				msg( "log", "Found an upload without .changes and with no ",
					        ".dsc file\n" );
				msg( "log", "Not sending a report, because probably ",
					        "recompilation job\n" );
			}
			elsif ($maint) {
				init_mail();
				$main::mail_addr = $maint;
				$main::mail_addr = $1 if $main::mail_addr =~ /<([^>]*)>/;
				$main::mail_subject = "Incomplete upload found in ".
									  "Debian upload queue";
				msg( "mail", "Probably you are the uploader of the following ".
							 "file(s) in\n" );
				msg( "mail", "the Debian upload queue directory:\n  " );
				msg( "mail", join( "\n  ", @job_files ), "\n" );
				msg( "mail", "This looks like an upload, but a .changes file ".
							 "is missing, so the job\n" );
				msg( "mail", "cannot be processed.\n\n" );
				msg( "mail", "If no .changes file arrives within ",
							 print_time( $conf::stray_remove_timeout - $age ),
							 ", the files will be deleted.\n\n" );
				msg( "mail", "If you didn't upload those files, please just ".
							 "ignore this message.\n" );
				finish_mail();
				msg( "log", "Sending problem report for an upload without a ".
							".changes\n" );
				msg( "log", "Maintainer: $maint\n" );
			}
			else {
				msg( "log", "Found an upload without .changes, but can't ".
							"find a maintainer address\n" );
			}
			msg( "log", "Files: @job_files\n" );
			# remember we already have sent a mail regarding this file
			foreach ( @job_files ) {
				my @st = stat($_);
				next if !@st; # file may have disappeared in the meantime
				chmod +($st[ST_MODE] |= S_ISGID), $_;
			}
		}
		else {
			debug( "found stray file $file, deleting in ",
				   print_time($conf::stray_remove_timeout - $age) );
		}
	}

  end_run:
	$main::dstat = "i";
	write_status_file() if $conf::statusdelay;
}

#
# process one .changes file
#
sub process_changes($\@) {
	my $changes = shift;
	my $keep_list = shift;
	my( $pgplines, @files, @filenames, @changes_stats, $file, $failure_file,
	    $retries, $last_retry, $upload_time, $file, $do_report, $ls_l,
	    $problems_reported, $errs, $pkgname, $signator );
	local( *CHANGES );
	local( *FAILS );

	format_status_str( $main::current_changes, $changes );
	$main::dstat = "c";
	write_status_file() if $conf::statusdelay;

	@$keep_list = ();
	msg( "log", "processing $changes\n" );

	# parse the .changes file
	open( CHANGES, "<$changes" )
		or die "Cannot open $changes: $!\n";
	$pgplines = 0;
	$main::mail_addr = "";
	@files = ();
	outer_loop: while( <CHANGES> ) {
		if (/^---+(BEGIN|END) PGP .*---+$/) {
			++$pgplines;
		}
		elsif (/^Maintainer:\s*/i) {
			chomp( $main::mail_addr = $' );
			$main::mail_addr = $1 if $main::mail_addr =~ /<([^>]*)>/;
		}
		elsif (/^Source:\s*/i) {
			chomp( $pkgname = $' );
			$pkgname =~ s/\s+$//;
		}
		elsif (/^Files:/i) {
			while( <CHANGES> ) {
				redo outer_loop if !/^\s/;
				my @field = split( /\s+/ );
				next if @field != 6;
				# forbid shell meta chars in the name, we pass it to a
				# subshell several times...
				$field[5] =~ /^([a-zA-Z0-9.+_:@=%-]+)/;
				if ($1 ne $field[5]) {
					msg( "log", "found suspicious filename $field[5]\n" );
					msg( "mail", "File '$field[5]' mentioned in $changes\n",
						 "has bad characters in its name. Removed.\n" );
					rm( $field[5] );
					next;
				}
				push( @files, { md5  => $field[1],
								size => $field[2],
								name => $field[5] } );
				push( @filenames, $field[5] );
				debug( "includes file $field[5], size $field[2], ",
					   "md5 $field[1]" );
			}
		}
	}
	close( CHANGES );

	# tell check_dir that the files mentioned in this .changes aren't stray,
	# we know about them somehow
	@$keep_list = @filenames;

	# some consistency checks
	if (!$main::mail_addr) {
		msg( "log,mail", "$changes doesn't contain a Maintainer: field; ".
			 "cannot process\n" );
		goto remove_only_changes;
	}
	if ($main::mail_addr !~ /^\S+\@\S+\.\S+/) {
		# doesn't look like a mail address, maybe only the name
		my( $new_addr, @addr_list );
		if ($new_addr = try_to_get_mail_addr( $main::mail_addr, \@addr_list )){
			# substitute (unique) found addr, but give a warning
			msg( "mail", "(The Maintainer: field didn't contain a proper ".
						 "mail address.\n" );
			msg( "mail", "Looking for `$main::mail_addr' in the Debian ".
						 "keyring gave your address\n" );
			msg( "mail", "as unique result, so I used this.)\n" );
			msg( "log", "Substituted $new_addr for malformed ".
						"$main::mail_addr\n" );
			$main::mail_addr = $new_addr;
		}
		else {
			# not found or not unique: hold the job and inform queue maintainer
			my $old_addr = $main::mail_addr;
			$main::mail_addr = $conf::maintainer_mail;
			msg( "mail", "The job $changes doesn't have a correct email\n" );
			msg( "mail", "address in the Maintainer: field:\n" );
			msg( "mail", "  $old_addr\n" );
			msg( "mail", "A check for this in the Debian keyring gave:\n" );
			msg( "mail", @addr_list ?
						 "  " . join( ", ", @addr_list ) . "\n" :
						 "  nothing\n" );
			msg( "mail", "Please fix this manually\n" );
			msg( "log", "Bad Maintainer: field in $changes: $old_addr\n" );
			goto remove_only_changes;
		}
	}
	if ($pgplines < 3) {
		msg( "log,mail", "$changes isn't signed with PGP\n" );
		msg( "log", "(uploader $main::mail_addr)\n" );
		goto remove_only_changes;
	}
	if (!@files) {
		msg( "log,mail", "$changes doesn't mention any files\n" );
		msg( "log", "(uploader $main::mail_addr)\n" );
		goto remove_only_changes;
	}

	# check for packages that shouldn't be processed
	if (grep( $_ eq $pkgname, @conf::nonus_packages )) {
		msg( "log,mail", "$pkgname is a package that must be uploaded ".
			             "to nonus.debian.org\n" );
		msg( "log,mail", "instead of master.\n" );
		msg( "log,mail", "Job rejected and removed all files belonging ".
						 "to it:\n" );
		msg( "log,mail", "  ", join( ", ", @filenames ), "\n" );
		rm( $changes, @filenames );
		return;
	}

	$failure_file = $changes . ".failures";
	$retries = $last_retry = 0;
	if (-f $failure_file) {
		open( FAILS, "<$failure_file" )
			or die "Cannot open $failure_file: $!\n";
		my $line = <FAILS>;
		close( FAILS );
		( $retries, $last_retry ) = ( $1, $2 ) if $line =~ /^(\d+)\s+(\d+)$/;
		push( @$keep_list, $failure_file );
	}

	# run PGP on the file to check the signature
	if (!($signator = pgp_check( $changes ))) {
		msg( "log,mail", "$changes has bad PGP signature!\n" );
		msg( "log", "(uploader $main::mail_addr)\n" );
	  remove_only_changes:
		msg( "log,mail", "Removing $changes, but keeping its associated ",
			             "files for now.\n" );
		rm( $changes );
		# Set SGID bit on associated files, so that the test for Debian files
		# without a .changes doesn't consider them.
		foreach ( @filenames ) {
			my @st = stat($_);
			next if !@st; # file may have disappeared in the meantime
			chmod +($st[ST_MODE] |= S_ISGID), $_;
		}
		return;
	}
	elsif ($signator eq "LOCAL ERROR") {
		# An error has appened when starting pgp... Don't process the file,
		# but also don't delete it
		debug( "Can't PGP check $changes -- don't process it for now" );
		return;
	}

	die "Cannot stat $changes (??): $!\n"
		if !(@changes_stats = stat( $changes ));
	# Make $upload_time the maximum of all modification times of files
	# related to this .changes (and the .changes it self). This is the
	# last time something changes to these files.
	$upload_time = $changes_stats[ST_MTIME];
	for $file ( @files ) {
		my @stats;
		next if !(@stats = stat( $file->{"name"} ));
		$file->{"stats"} = \@stats;
		$upload_time = $stats[ST_MTIME] if $stats[ST_MTIME] > $upload_time;
	}

	$do_report = (time - $upload_time) > $conf::problem_report_timeout;
	$problems_reported = $changes_stats[ST_MODE] & S_ISGID;
	# if any of the files is newer than the .changes' ctime (the time
	# we sent a report and set the sticky bit), send new problem reports
	if ($problems_reported && $changes_stats[ST_CTIME] < $upload_time) {
		$problems_reported = 0;
		chmod +($changes_stats[ST_MODE] &= ~S_ISGID), $changes;
		debug( "upload_time>changes-ctime => resetting problems reported" );
	}
	debug( "do_report=$do_report problems_reported=$problems_reported" );
	
	# now check all files for correct size and md5 sum
	for $file ( @files ) {
		my $filename = $file->{"name"};
		if (!defined( $file->{"stats"} )) {
			# could be an upload that isn't complete yet, be quiet,
			# but don't process the file;
			msg( "log,mail", "$filename doesn't exist\n" )
				if $do_report && !$problems_reported;
			msg( "log", "$filename doesn't exist (ignored for now)\n" )
				if !$do_report;
			msg( "log", "$filename doesn't exist (already reported)\n" )
				if $problems_reported;
			++$errs;
		}
		elsif ($file->{"stats"}->[ST_SIZE] < $file->{"size"} && !$do_report) {
			# could be an upload that isn't complete yet, be quiet,
			# but don't process the file
			msg( "log", "$filename is too small (ignored for now)\n" );
			++$errs;
		}
		elsif ($file->{"stats"}->[ST_SIZE] != $file->{"size"}) {
			msg( "log,mail", "$filename has incorrect size; deleting it\n" );
			rm( $filename );
			++$errs;
		}
		elsif (md5sum( $filename ) ne $file->{"md5"}) {
			msg( "log,mail", "$filename has incorrect md5 checksum; ",
				             "deleting it\n" );
			rm( $filename );
			++$errs;
		}
	}

	if ($errs) {
		if ((time - $upload_time) > $conf::bad_changes_timeout) {
			# if a .changes fails for a really long time (several days
			# or so), remove it and all associated files
			msg( "log,mail",
				 "$changes couldn't be processed for ",
				 int($conf::bad_changes_timeout/(60*60)),
				 " hours and is now deleted\n" );
			msg( "log,mail",
				 "All files it mentions are also removed:\n" );
			msg( "log,mail", "  ", join( ", ", @filenames ), "\n" );
			rm( $changes, @filenames, $failure_file );
		}
		elsif ($do_report && !$problems_reported) {
			# otherwise, send a problem report, if not done already
			msg( "mail",
				 "Due to the errors above, the .changes file couldn't ",
				 "be processed.\n",
				 "Please fix the problems for the upload to happen.\n" );
			# remember we already have sent a mail regarding this file
			debug( "Sending problem report mail and setting SGID bit" );
			chmod +($changes_stats[ST_MODE] |= S_ISGID), $changes;
		}
		# else: be quiet
		
		return;
	}

	# if this upload already failed earlier, wait until the delay requirement
	# is fulfilled
	if ($retries > 0 && (time - $last_retry) <
		($retries == 1 ? $conf::upload_delay_1 : $conf::upload_delay_2)) {
		msg( "log", "delaying retry of upload\n" );
		return;
	}

	# check if the job is already present on master
	# (moved to here, to avoid bothering master as long as there are errors in
	# the job)
	if ($ls_l = is_on_master( $changes )) {
		msg( "log,mail", "$changes is already present on master:\n" );
		msg( "log,mail", "$ls_l\n" );
		msg( "mail", "Either you already uploaded it, or someone else ",
			         "came first.\n" );
		msg( "log,mail", "Job $changes removed.\n" );
		rm( $changes, @filenames, $failure_file );
		return;
	}
		
	# clear sgid bit before upload, scp would copy it to master. We don't need
	# it anymore, we know there are no problems if we come here. Also change
	# mode of files to 644 if this should be done locally.
	$changes_stats[ST_MODE] &= ~S_ISGID;
	if (!$conf::chmod_on_master) {
		$changes_stats[ST_MODE] &= ~0777;
		$changes_stats[ST_MODE] |= 0644;
	}
	chmod +($changes_stats[ST_MODE]), $changes;

	# try uploading to master
	if (!copy_to_master( $changes, @filenames )) {
		# if the upload failed, increment the retry counter and remember the
		# current time; both things are written to the .failures file. Don't
		# increment the fail counter if the error was due to incoming
		# unwritable.
		return if !$main::incoming_writable;
		if (++$retries >= $conf::max_upload_retries) {
			msg( "log,mail",
				 "$changes couldn't be uploaded for $retries times now.\n" );
			msg( "log,mail",
				 "Giving up and removing it and its associated files:\n" );
			msg( "log,mail", "  ", join( ", ", @filenames ), "\n" );
			rm( $changes, @filenames, $failure_file );
		}
		else {
			$last_retry = time;
			if (open( FAILS, ">$failure_file" )) {
				print FAILS "$retries $last_retry\n";
				close( FAILS );
				chmod( 0600, $failure_file )
					or die "Cannot set modes of $failure_file: $!\n";
			}
			push( @$keep_list, $failure_file );
			debug( "now $retries failed uploads" );
			msg( "mail",
				 "The upload will be retried in ",
				 print_time( $retries == 1 ? $conf::upload_delay_1 :
							 $conf::upload_delay_2 ), "\n" );
		}
		return;
	}

	# If the files were uploaded ok, remove them
	rm( $changes, @filenames, $failure_file );

	msg( "mail", "$changes uploaded successfully to $conf::master\n" );
	msg( "mail", "along with the files:\n  ",
		         join( "\n  ", @filenames ), "\n" );
	msg( "log", "$changes processed successfully (uploader $main::mail_addr)\n" );

	# Check for files that have the same stem as the .changes (and weren't
	# mentioned there) and delete them. It happens often enough that people
	# upload a .orig.tar.gz where it isn't needed and also not in the
	# .changes. Explicitly deleting it (and not waiting for the
	# $stray_remove_timeout) reduces clutter in the queue dir and maybe also
	# educates uploaders :-)

	my $pattern = debian_file_stem( $changes );
	my $spattern = substr( $pattern, 0, -1 ); # strip off '*' at end
	my @other_files = glob($pattern);
	# filter out files that have a Debian revision at all and a different
	# revision. Those belong to a different upload.
	if ($changes =~ /^\Q$spattern\E-([\d.+-]+)/) {
		my $this_rev = $1;
		@other_files = grep( !/^\Q$spattern\E-([\d.+-]+)/ || $1 eq $this_rev,
							 @other_files);
	}
	if (@other_files) {
		rm( @other_files );
		msg( "mail", "\nThe following file(s) seemed to belong to the same ".
					 "upload, but weren't listed\n" );
		msg( "mail", "in the .changes file:\n  " );
		msg( "mail", join( "\n  ", @other_files ), "\n" );
		msg( "mail", "They have been deleted.\n" );
		msg( "log", "Deleted files in upload not in $changes: @other_files\n" );
	}
}

#
# process one .commands file
#
sub process_commands($) {
	my $commands = shift;
	my( @cmds, $cmd, $pgplines, $signator );
	local( *COMMANDS );
	
	format_status_str( $main::current_changes, $commands );
	$main::dstat = "c";
	write_status_file() if $conf::statusdelay;
	
	msg( "log", "processing $commands\n" );

	# parse the .commands file
	if (!open( COMMANDS, "<$commands" )) {
		msg( "log", "Cannot open $commands: $!\n" );
		return;
	}
	$pgplines = 0;
	$main::mail_addr = "";
	@cmds = ();
	outer_loop: while( <COMMANDS> ) {
		if (/^---+(BEGIN|END) PGP .*---+$/) {
			++$pgplines;
		}
		elsif (/^Uploader:\s*/i) {
			chomp( $main::mail_addr = $' );
			$main::mail_addr = $1 if $main::mail_addr =~ /<([^>]*)>/;
		}
		elsif (/^Commands:/i) {
			$_ = $';
			for(;;) {
				s/^\s*(.*)\s*$/$1/; # delete whitespace at both ends
				if (!/^\s*$/) {
					push( @cmds, $_ );
					debug( "includes cmd $_" );
				}
				last outer_loop if !defined( $_ = scalar(<COMMANDS>) );
				chomp;
				redo outer_loop if !/^\s/ || /^$/;
			}
		}
	}
	close( COMMANDS );
	
	# some consistency checks
	if (!$main::mail_addr || $main::mail_addr !~ /^\S+\@\S+\.\S+/) {
		msg( "log,mail", "$commands contains no or bad Uploader: field: ".
						 "$main::mail_addr\n" );
		msg( "log,mail", "cannot process $commands\n" );
		$main::mail_addr = "";
		goto remove;
	}
	msg( "log", "(command uploader $main::mail_addr)\n" );

	if ($pgplines < 3) {
		msg( "log,mail", "$commands isn't signed with PGP\n" );
		goto remove;
	}
	
	# run PGP on the file to check the signature
	if (!($signator = pgp_check( $commands ))) {
		msg( "log,mail", "$commands has bad PGP signature!\n" );
	  remove:
		msg( "log,mail", "Removing $commands\n" );
		rm( $commands );
		return;
	}
	elsif ($signator eq "LOCAL ERROR") {
		# An error has appened when starting pgp... Don't process the file,
		# but also don't delete it
		debug( "Can't PGP check $commands -- don't process it for now" );
		return;
	}
	msg( "log", "(PGP signature by $signator)\n" );

	# now process commands
	msg( "mail", "Log of processing your commands file $commands:\n\n" );
	foreach $cmd ( @cmds ) {
		my @word = split( /\s+/, $cmd );
		msg( "mail,log", "> @word\n" );
		next if @word < 1;
		
		if ($word[0] eq "rm") {
			my( @files, $file, @removed );
			foreach ( @word[1..$#word] ) {
				if (m,/,) {
					msg( "mail,log", "$_: filename may not contain slashes\n" );
				}
				elsif (/[*?[]/) {
					# process wildcards
					my $pat = quotemeta($_);
					$pat =~ s/\\\*/.*/g;
					$pat =~ s/\\\?/.?/g;
					$pat =~ s/\\([][])/$1/g;
					opendir( DIR, "." );
					push( @files, grep /^$pat$/, readdir(DIR) );
					closedir( DIR );
				}
				else {
					push( @files, $_ );
				}
			}
			if (!@files) {
				msg( "mail,log", "No files to delete\n" );
			}
			else {
				@removed = ();
				foreach $file ( @files ) {
					if (!-f $file) {
						msg( "mail,log", "$file: no such file\n" );
					}
					elsif ($file =~ /$conf::keep_files/) {
						msg( "mail,log", "$file is protected, cannot ".
							 "remove\n" );
					}
					elsif (!unlink( $file )) {
						msg( "mail,log", "$file: rm: $!\n" );
					}
					else {
						push( @removed, $file );
					}
				}
				msg( "mail,log", "Files removed: @removed\n" ) if @removed;
			}
		}
		elsif ($word[0] eq "mv") {
			if (@word != 3) {
				msg( "mail,log", "Wrong number of arguments\n" );
			}
			elsif ($word[1] =~ m,/,) {
				msg( "mail,log", "$word[1]: filename may not contain slashes\n" );
			}
			elsif ($word[2] =~ m,/,) {
				msg( "mail,log", "$word[2]: filename may not contain slashes\n" );
			}
			elsif (!-f $word[1]) {
				msg( "mail,log", "$word[1]: no such file\n" );
			}
			elsif (-e $word[2]) {
				msg( "mail,log", "$word[2]: file exists\n" );
			}
			elsif ($word[1] =~ /$conf::keep_files/) {
				msg( "mail,log", "$word[1] is protected, cannot rename\n" );
			}
			else {
				if (!rename( $word[1], $word[2] )) {
					msg( "mail,log", "rename: $!\n" );
				}
				else {
					msg( "mail,log", "OK\n" );
				}
			}
		}
		else {
			msg( "mail,log", "unknown command $word[0]\n" );
		}
	}
	rm( $commands );
	msg( "log", "-- End of $commands processing\n" );
}

#
# check if a file is already on master
#
sub is_on_master($) {
	my $file = shift;
	my $msg;
	
	debug( "executing $conf::ssh $conf::ssh_options $conf::master -l ".
		   "$conf::masterlogin \'cd $conf::masterdir; ls -l $file\'" );
    $SIG{"ALRM"} = sub { die "timeout in ssh ls -l\n" } ;
    alarm( $conf::remote_timeout );
	eval <<'EOM';
	$msg = `$conf::ssh $conf::ssh_options $conf::master -l $conf::masterlogin \'cd $conf::masterdir; ls -l $file\' 2>&1`;
EOM
	alarm( 0 );
	$msg = $@ if $@;
	chomp( $msg );
	debug( "exit status: $?, output was: $msg" );

	return "" if $? && $msg =~ /^ls:.*no such file/i; # file not present
	msg( "log", "strange ls -l output on master:\n", $msg ), return ""
		if $? || $@; # some other error, but still try to upload

	# ls -l returned 0 -> file already there
	$msg =~ s/\s\s+/ /g; # make multiple spaces into one, to save space
	return $msg;
}

#
# copy a list of files to master
#
sub copy_to_master(@) {
	my @files = @_;
	my( @md5sum, @expected_files, $sum, $name, $msgs );
	
	$main::dstat = "u";
	write_status_file() if $conf::statusdelay;

	# copy the files
	debug( "executing $conf::scp $conf::ssh_options @files ".
		   "$conf::masterlogin\@$conf::master:$conf::masterdir" );
	# set a timeout
    $SIG{"ALRM"} = sub { die "timeout in scp\n" } ;
    alarm( $conf::remote_timeout );
	eval <<'EOM';
	$msgs = `$conf::scp $conf::ssh_options @files $conf::masterlogin\@$conf::master:$conf::masterdir 2>&1`;
EOM
	alarm( 0 );
	goto err if $? || $@;

	# check md5sums on master against our own
	debug( "executing $conf::ssh $conf::ssh_options $conf::master ".
		   "-l $conf::masterlogin \'cd $conf::masterdir; md5sum @files\'" );
    $SIG{"ALRM"} = sub { die "timeout in ssh md5sum\n" } ;
    alarm( $conf::remote_timeout );
	eval <<'EOM';
	@md5sum = `$conf::ssh $conf::ssh_options $conf::master -l $conf::masterlogin \'cd $conf::masterdir; md5sum @files\' 2>&1`;
EOM
	alarm( 0 );
	goto err if $? || $@;

	@expected_files = @files;
	foreach ( @md5sum ) {
		chomp;
		($sum,$name) = split;
		next if !grep { $_ eq $name } @files; # a file we didn't upload??
		next if $sum eq "md5sum:"; # looks like an error message
		if ($sum ne md5sum( $name )) {
			msg( "log,mail", "Upload of $name to $conf::master failed ",
				 "(md5sum mismatch)\n" );
			goto err;
		}
		# seen that file, remove it from expect list
		@expected_files = map { $_ eq $name ? () : $_ } @expected_files;
	}
	if (@expected_files) {
		msg( "log,mail", "Failed to upload the files\n" );
		msg( "log,mail", "  ", join( ", ", @expected_files ), "\n" );
		msg( "log,mail", "(Not present on master after upload)\n" );
		goto err;
	}

	if ($conf::chmod_on_master) {
		# change file's mode explicitly to 644 on master
		debug( "executing $conf::ssh $conf::ssh_options $conf::master ".
			   "-l $conf::masterlogin \'cd $conf::masterdir; ".
			   "chmod 644 @files\'" );
		$SIG{"ALRM"} = sub { die "timeout in ssh chmod\n" } ;
		alarm( $conf::remote_timeout );
		eval <<'EOM';
		$msgs = `$conf::ssh $conf::ssh_options $conf::master -l $conf::masterlogin \'cd $conf::masterdir; chmod 644 @files\' 2>&1`;
EOM
		alarm( 0 );
		goto err if $? || $@;
	}

	$main::dstat = "c";
	write_status_file() if $conf::statusdelay;
	return 1;
	
  err:
	chomp( $@ ) if $@;
	msg( "log,mail", "Upload to $conf::master failed, ",
		 $? ? "last exit status ".sprintf( "%s", $?>>8 ) : "",
		 $@ ? $@ : "", "\n" );
	msg( "log,mail", "Error messages from scp:\n", $msgs )
		if $msgs;

	# If "permission denied" was among the errors, test if the incoming is
	# writable at all.
	if ($msgs =~ /(permission denied|read-?only file)/i) {
		if (!check_incoming_writable()) {
			msg( "log,mail", "(The incoming directory seems to be ",
				             "unwritable.)\n" );
		}
	}

	# remove bad files or an incomplete upload on master
	debug( "executing $conf::ssh $conf::master $conf::ssh_options ".
		   "-l $conf::masterlogin \'cd $conf::masterdir; rm -f @files\'" );
	$SIG{"ALRM"} = sub { die "timeout\n" } ;
	alarm( $conf::remote_timeout );
	eval <<'EOM';
	system( "$conf::ssh $conf::master $conf::ssh_options -l ".
		    "$conf::masterlogin \'cd $conf::masterdir; rm -f @files\'" );
EOM
	alarm( 0 );
	$main::dstat = "c";
	write_status_file() if $conf::statusdelay;
	return 0;
}

#
# check if a file is correctly signed with PGP
#
sub pgp_check($) {
	my $file = shift;
	my $output = "";
	my $signator;
	local( *PIPE );

	msg( "log", "Warning: $conf::keyring doesn't exist!\n" )
		if ! -f $conf::keyring;
	debug( "executing $conf::pgp -f +batchmode +verbose=0 ".
		   "+pubring=$conf::keyring <$file" );
	if (!open( PIPE, "$conf::pgp -f +batchmode +verbose=0 ".
			   "+pubring=$conf::keyring <'$file' 2>&1 >/dev/null |" )) {
		msg( "log", "Can't open pipe to $conf::pgp: $!\n" );
		return "LOCAL ERROR";
	}
	$output .= $_ while( <PIPE> );
	close( PIPE );
	$output =~ s/\a//g; # remove bells inside PGP output
	
	if ($?) {
		msg( "log,mail", "PGP signature check failed on $file\n" );
		msg( "mail", $output );
		msg( "log,mail", "(Exit status ", $? >> 8, ")\n" );
		return "";
	}

	$output =~ /^good signature from user "(.*)"\.$/im;
	($signator = $1) ||= "unknown signator";
	debug( "PGP signature ok (by $signator)" );
	return $signator;
}


# ---------------------------------------------------------------------------
#							  the status daemon
# ---------------------------------------------------------------------------

#
# fork a subprocess that watches the 'status' FIFO
# 
# that process blocks until someone opens the FIFO, then sends a
# signal (SIGUSR1) to the main process, expects 
#
sub fork_statusd() {
	my $statusd_pid;
	my $main_pid = $$;
	my $errs;
	local( *STATFIFO );

	$statusd_pid = open( STATUSD, "|-" );
	die "cannot fork: $!\n" if !defined( $statusd_pid );
	# parent just returns
	if ($statusd_pid) {
		msg( "log", "forked status daemon (pid $statusd_pid)\n" );
		return $statusd_pid;
	}
	# child: the status FIFO daemon

	# ignore SIGPIPE here, in case some closes the FIFO without completely
	# reading it
	$SIG{"PIPE"} = "IGNORE";
	# also ignore SIGCLD, we don't want to inherit the restart-statusd handler
	# from our parent
	$SIG{"CHLD"} = "DEFAULT";
	
	rm( $conf::statusfile );
	$errs = `$conf::mkfifo $conf::statusfile`;
	die "$main::progname: cannot create named pipe $conf::statusfile: $errs"
		if $?;
	chmod( 0644, $conf::statusfile )
		or die "Cannot set modes of $conf::statusfile: $!\n";

	# close log file, so that log rotating works
	close( LOG );
	close( STDOUT );
	close( STDERR );
	
	while( 1 ) {
		my( $status, $mup, $incw, $ds, $next_run, $last_ping, $currch, $l );

		# open the FIFO for writing; this blocks until someone (probably ftpd)
		# opens it for reading
		open( STATFIFO, ">$conf::statusfile" )
			or die "Cannot open $conf::statusfile\n";
		select( STATFIFO );
		# tell main daemon to send us status infos
		kill( $main::signo{"USR1"}, $main_pid );

		# get the infos from stdin; must loop until enough bytes received!
		my $expect_len = 3 + 2*STATNUM_LEN + STATSTR_LEN;
		for( $status = ""; ($l = length($status)) < $expect_len; ) {
			sysread( STDIN, $status, $expect_len-$l, $l );
		}

		# disassemble the status byte stream
		my $pos = 0;
		foreach ( [ mup => 1 ], [ incw => 1 ], [ ds => 1 ],
				  [ next_run => STATNUM_LEN ], [ last_ping => STATNUM_LEN ],
				  [ currch => STATSTR_LEN ] ) {
			eval "\$$_->[0] = substr( \$status, $pos, $_->[1] );";
			$pos += $_->[1];
		}
		$currch =~ s/\n+//g;

		print_status( $mup, $incw, $ds, $next_run, $last_ping, $currch );
		close( STATFIFO );

		# This sleep is necessary so that we can't reopen the FIFO
		# immediately, in case the reader hasn't closed it yet if we get to
		# the open again. Is there a better solution for this??
		sleep 1;
	}
}

#
# update the status file, in case we use a plain file and not a FIFO
#
sub write_status_file() {

	return if !$conf::statusfile;
	
	open( STATFILE, ">$conf::statusfile" ) or
		(msg( "log", "Could not open $conf::statusfile: $!\n" ), return);
	my $oldsel = select( STATFILE );

	print_status( $main::master_up, $main::incoming_writable, $main::dstat,
				  $main::next_run, $main::last_ping_time,
				  $main::current_changes );

	select( $oldsel );
	close( STATFILE );
}

sub print_status($$$$$$) {
	my $mup = shift;
	my $incw = shift;
	my $ds = shift;
	my $next_run = shift;
	my $last_ping = shift;
	my $currch = shift;
	my $approx;
	my $version;

	($version = 'Release: 0.8 $Revision: 1.47 $') =~ s/\$ ?//g;
	print "debianqueued $version\n";
	
	$approx = $conf::statusdelay ? "approx. " : "";
	
	if ($mup eq "0") {
		print "$conf::master is down, queue pausing\n";
		return;
	}
	else {
		print "$conf::master seems to be up, last ping $approx",
			  print_time(time-$last_ping), " ago\n";
	}

	if ($incw eq "0") {
		print "The incoming directory is not writable, queue pausing\n";
		return;
	}
	
	if ($ds eq "i") {
		print "Next queue check in $approx",print_time($next_run-time),"\n";
		return;
	}
	elsif ($ds eq "c") {
		print "Checking queue directory\n";
	}
	elsif ($ds eq "u") {
		print "Uploading to $conf::master\n";
	}
	else {
		print "Bad status data from daemon: \"$mup$incw$ds\"\n";
		return;
	}
	
	print "Current job is $currch\n" if $currch;
}		

#
# format a number for sending to statusd (fixed length STATNUM_LEN)
#
sub format_status_num(\$$) {
	my $varref = shift;
	my $num = shift;
	
	$$varref = sprintf "%".STATNUM_LEN."d", $num;
}

#
# format a string for sending to statusd (fixed length STATSTR_LEN)
#
sub format_status_str(\$$) {
	my $varref = shift;
	my $str = shift;

	$$varref = substr( $str, 0, STATSTR_LEN );
	$$varref .= "\n" x (STATSTR_LEN - length($$varref));
}

#
# send a status string to the status daemon
#
# Avoid all operations that could call malloc() here! Most libc
# implementations aren't reentrant, so we may not call it from a
# signal handler. So use only already-defined variables.
#
sub send_status() {
    local $! = 0; # preserve errno
	
	# re-setup handler, in case we have broken SysV signals
	$SIG{"USR1"} = \&send_status;

	syswrite( STATUSD, $main::master_up, 1 );
	syswrite( STATUSD, $main::incoming_writable, 1 );
	syswrite( STATUSD, $main::dstat, 1 );
	syswrite( STATUSD, $main::next_run, STATNUM_LEN );
	syswrite( STATUSD, $main::last_ping_time, STATNUM_LEN );
	syswrite( STATUSD, $main::current_changes, STATSTR_LEN );
}


# ---------------------------------------------------------------------------
#							  utility functions
# ---------------------------------------------------------------------------


#
# check if master is alive (code stolen from Net::Ping.pm)
#
sub check_alive(;$) {
    my $timeout = shift;
    my( $saddr, $ret, $master_ip );
    local( *PINGSOCK );

    $timeout ||= 30;

	if (!($master_ip = (gethostbyname($conf::master))[4])) {
		msg( "log", "Cannot get IP address of $conf::master\n" );
		$ret = 0;
		goto out;
	}
    $saddr = pack( 'S n a4 x8', AF_INET, $main::echo_port, $master_ip );
    $SIG{'ALRM'} = sub { die } ;
    alarm( $timeout );
    
	$ret = $main::tcp_proto; # avoid warnings about unused variable
    $ret = 0;
    eval <<'EOM' ;
    return unless socket( PINGSOCK, PF_INET, SOCK_STREAM, $main::tcp_proto );
    return unless connect( PINGSOCK, $saddr );
    $ret = 1;
EOM
    alarm( 0 );
    close( PINGSOCK );
	msg( "log", "pinging $conf::master: " . ($ret ? "ok" : "down") . "\n" );
  out:
	$main::master_up = $ret ? "1" : "0";
	format_status_num( $main::last_ping_time, time );
	write_status_file() if $conf::statusdelay;
}

#
# check if incoming dir on master is writable
#
sub check_incoming_writable() {
	my $msg;
	my $testfile = ".debianqueued-testfile";
	
	debug( "executing $conf::ssh $conf::ssh_options $conf::master -l ".
		   "$conf::masterlogin \'cd $conf::masterdir; rm -f $testfile; touch $testfile; rm -f $testfile\'" );
    $SIG{"ALRM"} = sub { die "timeout in ssh\n" } ;
    alarm( $conf::remote_timeout );
	eval <<'EOM';
	$msg = `$conf::ssh $conf::ssh_options $conf::master -l $conf::masterlogin \'cd $conf::masterdir; rm -f $testfile; touch $testfile; rm -f $testfile\' 2>&1`;
EOM
	alarm( 0 );
	$msg = $@ if $@;
	chomp( $msg );
	debug( "exit status: $?, output was: $msg" );

	if (!$?) {
		# change incoming_writable only if ssh didn't return an error
		$main::incoming_writable =
			($msg =~ /(permission denied|read-?only file)/i) ? "0":"1";
	}
	else {
		debug( "local error, keeping old status" );
	}
	debug( "incoming_writable = $main::incoming_writable" );
	write_status_file() if $conf::statusdelay;
	return $main::incoming_writable;
}

#
# remove a list of files, log failing ones
#
sub rm(@) {
	my $done = 0;

	foreach ( @_ ) {
		(unlink $_ and ++$done)
			or $! == ENOENT or msg( "log", "Could not delete $_: $!\n" );
	}
	return $done;
}

#
# get md5 checksum of a file
#
sub md5sum($) {
	my $file = shift;
	my $line;

	chomp( $line = `$conf::md5sum $file` );
	debug( "md5sum($file): ", $? ? "exit status $?" :
		                      $line =~ /^(\S+)/ ? $1 : "match failed" );
	return $? ? "" : $line =~ /^(\S+)/ ? $1 : "";
}

#
# check if a file probably belongs to a Debian upload
#
sub is_debian_file($) {
	my $file = shift;
	return $file =~ /\.(deb|dsc|(diff|tar)\.gz)$/ &&
		   $file !~ /\.orig\.tar\.gz/;
}

#
# try to extract maintainer email address from some a non-.changes file
# return "" if not possible
#
sub get_maintainer($) {
	my $file = shift;
	my $maintainer = "";
	local( *F );
	
	if ($file =~ /\.diff\.gz$/) {
		# parse a diff 
		open( F, "$conf::gzip -dc '$file' 2>/dev/null |" ) or return "";
		while( <F> ) {
			# look for header line of a file */debian/control
			last if m,^\+\+\+\s+[^/]+/debian/control(\s+|$),;
		}
		while( <F> ) {
			last if /^---/; # end of control file patch, no Maintainer: found
			# inside control file patch look for Maintainer: field
			$maintainer = $1, last if /^\+Maintainer:\s*(.*)$/i;
		}
		while( <F> ) { } # read to end of file to avoid broken pipe
		close( F ) or return "";
	}
	elsif ($file =~ /\.(deb|dsc|tar\.gz)$/) {
		if ($file =~ /\.deb$/ && $conf::ar) {
			# extract control.tar.gz from .deb with ar, then let tar extract
			# the control file itself
			open( F, "($conf::ar p '$file' control.tar.gz | ".
				     "$conf::tar -xOf - ".
				     "--use-compress-program $conf::gzip ".
				     "control) 2>/dev/null |" )
				or return "";
		}
		elsif ($file =~ /\.dsc$/) {
			# just do a plain grep
			debug( "get_maint: .dsc, no cmd" );
			open( F, "<$file" ) or return "";
		}
		elsif ($file =~ /\.tar\.gz$/) {
			# let tar extract a file */debian/control
			open(F, "$conf::tar -xOf '$file' ".
				    "--use-compress-program $conf::gzip ".
				    "\\*/debian/control 2>&1 |")
				or return "";
		}
		else {
			return "";
		}
		while( <F> ) {
			$maintainer = $1, last if /^Maintainer:\s*(.*)$/i;
		}
		close( F ) or return "";
	}

	return $maintainer;
}

#
# return a pattern that matches all files that probably belong to one job
#
sub debian_file_stem($) {
	my $file = shift;
	my( $pkg, $version );

	# strip file suffix
	$file =~ s,\.(deb|dsc|changes|(orig\.)?tar\.gz|diff\.gz)$,,;
	# if not is *_* (name_version), can't derive a stem and return just
	# the file's name
	return $file if !($file =~ /^([^_]+)_([^_]+)/);
	($pkg, $version) = ($1, $2);
	# strip Debian revision from version
	$version =~ s/^(.*)-[\d.+-]+$/$1/;

	return "${pkg}_${version}*";
}
	
#
# output a messages to several destinations
#
# first arg is a comma-separated list of destinations; valid are "log"
# and "mail"; rest is stuff to be printed, just as with print
# 
sub msg($@) {
	my @dest = split( ',', shift );

	if (grep /log/, @dest ) {
		my $now = format_time();
		print LOG "$now ", @_;
	}

	if (grep /mail/, @dest ) {
		$main::mail_text .= join( '', @_ );
	}
}

#
# print a debug messages, if $debug is true
#
sub debug(@) {
	return if !$conf::debug;
	my $now = format_time();
	print LOG "$now DEBUG ", @_, "\n";
}

#
# intialize the "mail" destination of msg() (this clears text,
# address, subject, ...)
#
sub init_mail(;$) {
	my $file = shift;

	$main::mail_addr = "";
	$main::mail_text = "";
	$main::mail_subject = $file ? "Processing of $file" : "";
}

#
# finalize mail to be sent from msg(): check if something present, and
# then send out
#
sub finish_mail() {
	local( *MAIL );

	debug( "No mail for $main::mail_addr" )
		if $main::mail_addr && !$main::mail_text;
	return unless $main::mail_addr && $main::mail_text;

	if (!send_mail($main::mail_addr, $main::mail_subject, $main::mail_text)) {
		# store this mail in memory so it isn't lost if executing sendmail
		# failed.
		push( @main::stored_mails, { addr    => $main::mail_addr,
									 subject => $main::mail_subject,
									 text    => $main::mail_text } );
	}
	init_mail();

	# try to send out stored mails
	my $mailref;
	while( $mailref = shift(@main::stored_mails) ) {
		if (!send_mail( $mailref->{'addr'}, $mailref->{'subject'},
					    $mailref->{'text'} )) {
			unshift( @main::stored_mails, $mailref );
			last;
		}
	}
}

#
# send one mail
#
sub send_mail($$$) {
	my $addr = shift;
	my $subject = shift;
	my $text = shift;

	debug( "Sending mail to $addr" );
	debug( "executing $conf::mail -s '$subject' '$addr'" );
	if (!open( MAIL, "|$conf::mail -s '$subject' '$addr'" )) {
		msg( "log", "Could not open pipe to $conf::mail: $!\n" );
		return 0;
	}
	print MAIL $text;
	print MAIL "\nGreetings,\n\n\tYour Debian queue daemon\n";
	if (!close( MAIL )) {
		msg( "log", "$conf::mail failed (exit status ", $? >> 8, ")\n" );
		return 0;
	}
	return 1;
}

#
# try to find a mail address for a name in Debian keyring
#
sub try_to_get_mail_addr($$) {
	my $name = shift;
	my $listref = shift;

	@$listref = ();
	open( F, "$conf::pgp +batchmode +pubring=$conf::keyring -kv |" )
		or return "";
	while( <F> ) {
		if (/^pub / && / $name /) {
			/<([^>]*)>/;
			push( @$listref, $1 );
		}
	}
	close( F );

	return (@$listref == 1) ? $listref->[0] : "";
}

#
# return current time as string
#
sub format_time() {
	my $t;

	# omit weekday and year for brevity
	($t = localtime) =~ /^\w+\s(.*)\s\d+$/;
	return $1;
}

sub print_time($) {
	my $secs = shift;
	my $hours = int($secs/(60*60));

	$secs -= $hours*60*60;
	return sprintf "%d:%02d:%02d", $hours, int($secs/60), $secs % 60;
}

#
# block some signals during queue processing
# 
# This is just to avoid data inconsistency or uploads being aborted in the
# middle. Only "soft" signals are blocked, i.e. SIGINT and SIGTERM, try harder
# ones if you really want to kill the daemon at once.
#
sub block_signals() {
	POSIX::sigprocmask( SIG_BLOCK, $main::block_sigset );
}

sub unblock_signals() {
	POSIX::sigprocmask( SIG_UNBLOCK, $main::block_sigset );
}

#
# process SIGHUP: close log file and reopen it (for logfile cycling)
#
sub close_log($) {
	close( LOG );
	close( STDOUT );
	close( STDERR );

	open( LOG, ">>$conf::logfile" )
		or die "Cannot open my logfile $conf::logfile: $!\n";
	chmod( 0644, $conf::logfile )
		or msg( "log", "Cannot set modes of $conf::logfile: $!\n" );
	select( (select(LOG), $| = 1)[0] );

	open( STDOUT, ">&LOG" )
		or msg( "log", "$main::progname: Can't redirect stdout to ".
			    "$conf::logfile: $!\n" );
	open( STDERR, ">&LOG" )
		or msg( "log", "$main::progname: Can't redirect stderr to ".
			    "$conf::logfile: $!\n" );
	msg( "log", "Restart after SIGHUP\n" );
}

#
# process SIGCHLD: check if it was our statusd process
#
sub kid_died($) {
	my $pid;

	# reap statusd, so that it's no zombie when we try to kill(0) it
	waitpid( $main::statusd_pid, WNOHANG );

# Uncomment the following line if your Perl uses unreliable System V signal
# (i.e. if handlers reset to default if the signal is delivered).
# (Unfortunately, the re-setup can't be done in any case, since on some
# systems this will cause the SIGCHLD to be delivered again if there are
# still unreaped children :-(( )
	
#	 $SIG{"CHLD"} = \&kid_died; # resetup handler for SysV
}

sub restart_statusd() {
	# restart statusd if it died
	if (!kill( 0, $main::statusd_pid)) {
		close( STATUSD ); # close out pipe end
		$main::statusd_pid = fork_statusd();
	}
}

#
# process a fatal signal: cleanup and exit
#
sub fatal_signal($) {
	my $signame = shift;
	my $sig;
	
	# avoid recursions of fatal_signal in case of BSD signals
	foreach $sig ( qw( ILL ABRT BUS FPE SEGV PIPE ) ) {
		$SIG{$sig} = "DEFAULT";
	}

	if ($$ == $main::maind_pid) {
		# only the main daemon should do this
		kill( $main::signo{"TERM"}, $main::statusd_pid )
			if defined $main::statusd_pid;
		unlink( $conf::statusfile, $conf::pidfile );
	}
	msg( "log", "Caught SIG$signame -- exiting (pid $$)\n" );
	exit 1;
}


# Local Variables:
#  tab-width: 4
#  fill-column: 78
# End:
