OSSP CVS Repository

ossp - CVSROOT/shiela
Not logged in
[Honeypot]  [Browse]  [Directory]  [Home]  [Login
[Reports]  [Search]  [Ticket]  [Timeline
  [Raw

CVSROOT/shiela
#!/v/ossp/sw/bin/perl -w
##
##  OSSP shiela - CVS Access Control and Logging Facility
##  Copyright (c) 2000-2006 Ralf S. Engelschall <rse@engelschall.com>
##  Copyright (c) 2000-2006 The OSSP Project <http://www.ossp.org/>
##
##  This file is part of OSSP shiela, an access control and logging
##  facility for Concurrent Versions System (CVS) repositories
##  which can be found at http://www.ossp.org/pkg/tool/shiela/.
##
##  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.0 of the License, or (at your option) any later version.
##
##  This program is distributed in the hope that it will be useful,
##  but WITHOUT ANY WARRANTY; without even the implied warranty of
##  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
##  General Public License for more details.
##
##  You should have received a copy of the GNU General Public License
##  along with this file; if not, write to the Free Software
##  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
##  USA, or contact Ralf S. Engelschall <rse@engelschall.com>.
##
##  shiela.pl: control program (syntax: Perl)
##

my $version = '1.1.7';

require 5.005;

use strict;           # shipped with Perl since 5.000
use POSIX;            # shipped with Perl since 5.000
use IO::File;         # shipped with Perl since 5.003
use IO::Handle;       # shipped with Perl since 5.003
use IPC::Open2;       # shipped with Perl since 5.003
use Data::Dumper;     # shipped with Perl since 5.005
use Cwd qw(abs_path); # shipped with Perl since 5.005

#   DEBUGGING
$Data::Dumper::Purity = 1;
$Data::Dumper::Indent = 1;
$Data::Dumper::Terse  = 1;
$Data::Dumper::Pad    = "| ";

##  _________________________________________________________________
##
##  Main procedure.
##  _________________________________________________________________
##

#   Adjust program environment
$|++;
umask(002);
delete $ENV{TZ};
$ENV{PATH} = "/bin:/usr/bin:/sbin:/usr/sbin";

#   Generic program error handler
$SIG{__DIE__} = sub {
    my ($text) = @_;
    $text =~ s|\s+at\s+.*||s;
    print STDERR "cvs:shiela::ERROR: ". $text . ($! ? " ($!)" : "") . "\n";
    exit(1);
};

#   determine run-time and configuration information
my $PA = &pa_determine(@ARGV);
my $RT = &rt_determine_one($0, $version);
my $CF = &cf_determine(($PA->{OPT}->{config} || $RT->{cvsadmdir} . "/$RT->{name}.cfg"));
$RT = &rt_determine_two($RT, $CF);

#   DEBUGGING
if ($PA->{OPT}->{debug}) {
   print STDOUT "| \$PA =\n" . Data::Dumper::Dumper($PA);
   print STDOUT "| \$CF =\n" . Data::Dumper::Dumper($CF);
   print STDOUT "| \$RT =\n" . Data::Dumper::Dumper($RT);
}

#   dispatch into the various commands
my $rv = 1;
if ($PA->{OPT}->{hook} eq 'taginfo') {
    $rv = &hook_taginfo($PA, $RT, $CF);
}
elsif ($PA->{OPT}->{hook} eq 'admininfo') {
    $rv = &hook_admininfo($PA, $RT, $CF);
}
elsif ($PA->{OPT}->{hook} eq 'importinfo') {
    $rv = &hook_importinfo($PA, $RT, $CF);
}
elsif ($PA->{OPT}->{hook} eq 'commitinfo') {
    $rv = &hook_commitinfo($PA, $RT, $CF);
}
elsif ($PA->{OPT}->{hook} eq 'verifymsg') {
    $rv = &hook_verifymsg($PA, $RT, $CF);
}
elsif ($PA->{OPT}->{hook} eq 'loginfo') {
    $rv = &hook_loginfo($PA, $RT, $CF);
}
else {
    die "unknown processing stage (use --hook option)";
}
exit($rv);

##  _________________________________________________________________
##
##  Run-time information determination.
##
##  This is a two-stage process, because we need parts of the
##  information for parsing the configuration, but OTOH we need the
##  configuration for determining other information. To simply solve
##  this chicken and egg problem, we determine in two stages.
##  _________________________________________________________________
##

#   Determine run-time information (stage 1)
sub rt_determine_one {
    my ($program, $version) = @_;
    my $RT = {};

    #   program version and name
    $RT->{vers} = $version;
    $RT->{name} = ($program =~ m|^.*?([^/]+?)(?:\.[^/.]+)?$|)[0];

    #   program id and process group id
    $RT->{pid}  = $$;
    $RT->{pgrp} = getpgrp();

    #   supplied arguments
    $RT->{cvsroot} = $ENV{CVSROOT} or die 'unknown CVS root (set $CVSROOT variable)';
    $RT->{userid}  = ($ENV{CVSUSER} || $ENV{LOGNAME} || $ENV{LOGUSER} || $ENV{USER}) or die 'unknown CVS user';

    #   various directory paths
    $RT->{tmpdir}    = $ENV{TMPDIR} || $ENV{TEMPDIR} || '/tmp';
    $RT->{cvstmpdir} = (-w "$RT->{cvsroot}/CVSTMP" ? "$RT->{cvsroot}/CVSTMP" : $RT->{tmpdir});
    $RT->{cvsadmdir} = "$RT->{cvsroot}/CVSROOT";
    $RT->{cvslogdir} = (-w "$RT->{cvsroot}/CVSLOG" ? "$RT->{cvsroot}/CVSLOG" : $RT->{cvsadmdir});

    #   various file paths
    $RT->{logfile} = "$RT->{cvslogdir}/$RT->{name}.log";
    $RT->{tmpfile} = "$RT->{cvstmpdir}/$RT->{name}.$RT->{pgrp}";

    return $RT;
};

#   Determine run-time information (stage 2)
sub rt_determine_two {
    my ($RT, $CF) = @_;

    #   determine user information
    $RT->{username} = $CF->{Project}->{User}->{$RT->{userid}}->{name} ||
                      die "CVS user `$RT->{userid}' not found in OSSP shiela configuration";
    $RT->{usermail} = $CF->{Project}->{User}->{$RT->{userid}}->{mail} ||
                      "$RT->{userid}\@localhost";

    #   determine user's groups
    my @G = ();
    foreach my $group (keys(%{$CF->{Project}->{Group}})) {
        my @U = @{$CF->{Project}->{Group}->{$group}->{users}};
        if (grep(m/^$RT->{userid}$/, @U)) {
            push(@G, $group);
        }
    }
    $RT->{usergroups} = join(',', @G);

    #   optionally set environment variables (like PATH)
    foreach my $var (keys(%{$CF->{Environment}->{Setenv}})) {
        $ENV{PATH} = $CF->{Environment}->{Setenv}->{$var};
    }

    #   determine various program paths
    sub find_program {
        my ($name) = @_;
        my ($prog) = '';
        foreach my $dir (split(/:/, $ENV{PATH})) {
            if (-x "$dir/$name") {
                $prog = "$dir/$name";
                last;
            }
        }
        return $prog;
    }
    $RT->{sendmail} = $CF->{Environment}->{Program}->{sendmail} ||
                      &find_program("ssmtp") ||
                      &find_program("sendmail") ||
                      die "unable to find `sendmail' program";
    $RT->{cvs}      = $CF->{Environment}->{Program}->{cvs} ||
                      &find_program("cvs") ||
                      die "unable to find `cvs' program";
    $RT->{diff}     = $CF->{Environment}->{Program}->{diff} ||
                      &find_program("diff") ||
                      '';
    $RT->{xdelta}   = $CF->{Environment}->{Program}->{xdelta} ||
                      &find_program("xdelta") ||
                      '';
    $RT->{uuencode} = $CF->{Environment}->{Program}->{uuencode} ||
                      &find_program("uuencode") ||
                      '';

    #   pre-calculate a reasonable MIME boundary tag
    my $randtag;
    my @encode = (0..9, 'A'..'Z');
    srand(time ^ $$ or time ^ ($$ + ($$ << 15)));
    for (my $i = 0; $i < 20; $i++) {
        $randtag .= $encode[rand($#encode+1)];
    }
    $RT->{mimeboundary} = $randtag;

    #   determine CVS version and capabilities
    my $cmd = sprintf("%s --version 2>/dev/null", &qsa($RT->{cvs}));
    my $v = `$cmd`;
    $RT->{cvsvers} = '?';
    $RT->{cvsvers} = $1 if ($v =~ m|Concurrent\s+Versions\s+System\s+\(CVS\)\s+([\d.p]+)\s+|s);
    $RT->{cvsrse} = 0;
    $RT->{cvsrse} = 1 if ($v =~ m|\[RSE\]|s);
    die "$RT->{cvs} is not at least CVS 1.12" if ($RT->{cvsvers} !~ m|^1\.1[2-9]|);
    $RT->{useserver} = 0;
    $RT->{useserver} = 1 if ($v =~ m|server|s);

    #   determine path to history database
    $RT->{historydb} = $CF->{Repository}->{History} || "$RT->{cvslogdir}/$RT->{name}.db";
    $RT->{historydb} = $RT->{cvsroot}."/".$RT->{historydb} if ($RT->{historydb} !~ m|^/|);

    return $RT;
}

##  _________________________________________________________________
##
##  C-style configuration syntax parsing.
##
##  <config>    ::= <directive>
##                | <config> <directive>
##  <directive> ::= <name> ';'
##                | <name> <args> ';'
##  <args>      ::= <arg>
##                | <args> <arg>
##  <arg>       ::= '{' <config> '}'
##                | [^ \t\n]+
##
##  Note: For this task we usually would fire up the lovely
##  Parse::RecDescent or some other nifty grammar-based module which
##  supports parsing of nested constructs. But we want to operate in a
##  stand-alone environment (or at least an environment where we only
##  use Perl modules which are already shipped with the required Perl
##  version), so we have to do the parsing manually. Fortunately, in
##  our configuration syntax there is only one nesting: braced blocks.
##  So we do the crual approach and write a poor-man's parser which is
##  stand-alone and just slightly inefficient (only nested blocks are
##  re-parsed) by taking advantage of the fact that our syntax has this
##  very simple nesting only.
##  _________________________________________________________________
##

#   parse a text into a Perl structure and optionally use callbacks
sub parse_config {
    my ($t, $cb, $cba) = @_;

    #   pre-process syntax and strip comment and blank lines
    $t =~ s|^\s*#.+?$||mg;
    $t =~ s|^\s*$||mg;
    my $C = &parse_config_block($t, $cb, $cba, 0);

    #   parse a configuration block
    sub parse_config_block {
        my ($t, $cb, $cba, $l) = @_;
        my $B = [];
        my $A;
        while ($t ne '') {
            $t =~ s|^\s+||s && next;
            ($A, $t) = &parse_config_directive($t, $cb, $cba, $l);
            push(@{$B}, $A);
        }
        $B = $cb->($cba, 'B', $l, $B) if (defined($cb));
        return $B;
    }

    #   parse a single configuration directive
    sub parse_config_directive {
        my ($t, $cb, $cba, $l) = @_;
        my $bcnt = 0;
        my $qcnt = 0;
        my $A = [];
        my $a = '';
        while ($t ne '') {
            #   escaped meta character
            if ($t =~ m|^\\([^{}";])|s) {
                $a .= $1;
                $t = $';
            }
            #   plain argument mode
            elsif ($qcnt == 0 and $bcnt == 0) {
                if ($t =~ m|^;|s) {
                   $t = $';
                   last;
                }
                elsif ($t =~ m|^\{|s) {
                    push(@{$A}, $a) if ($a ne '');
                    $a = '';
                    $bcnt++;
                    $t = $';
                }
                elsif ($t =~ m|^"|s) {
                    $qcnt++;
                    $t = $';
                }
                elsif ($t =~ m|^\s+|s) {
                    push(@{$A}, $a) if ($a ne '');
                    $a = '';
                    $t = $';
                }
                elsif ($t =~ m|^([^;\{"\s]+)|s) {
                    $a .= $1;
                    $t = $';
                }
            }
            #   block mode
            elsif ($qcnt == 0 and $bcnt > 0) {
                if ($t =~ m|^\{|s) {
                    $bcnt++;
                    $a .= '{';
                    $t = $';
                }
                elsif ($t =~ m|^\}|s) {
                    $bcnt--;
                    $t = $';
                    if ($bcnt == 0) {
                        if ($a ne '') {
                            #   NESTING!
                            my $C = &parse_config_block($a, $cb, $cba, $l+1);
                            push(@{$A}, $C);
                            $a = '';
                        }
                    }
                    else {
                        $a .= '}';
                    }
                }
                elsif ($t =~ m|^([^\{\}]+)|s) {
                    $a .= $1;
                    $t = $';
                }
            }
            #   quoting mode
            elsif ($qcnt > 0 and $bcnt == 0) {
                if ($t =~ m|^\\"|s) {
                    $a .= '"';
                    $t = $';
                }
                elsif ($t =~ m|^"|s) {
                    $qcnt--;
                    $t = $';
                }
                elsif ($t =~ m|^([^"\\]+)|s) {
                    $a .= $1;
                    $t = $';
                }
            }
        }
        push(@{$A}, $a) if ($a ne '');
        $A = $cb->($cba, 'CMD', $l, $A) if (defined($cb));
        return ($A, $t);
    }

    return $C;
}

##  _________________________________________________________________
##
##  Determine OSSP shiela configuration.
##
##  We theoretically could directly operate on the syntax tree as
##  created by parse_config() above. But for convenience reasons and
##  to greatly simplify the processing, we use callback functions for
##  parse_config() and build an own configuration structure.
##  _________________________________________________________________
##

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

    #   read configuration file
    my $io = new IO::File "<$file"
        or die "unable to open configuration file `$file'";
    my $t = '';
    $t .= $_ while (<$io>);
    $io->close;

    #   parse configuration syntax into nested internal structure and
    #   in parallel (through a callback function) create the final
    #   configuration structure.
    my $CF = {
        'Project' => {
            'User' => {},
            'Group' => {}
        },
        'Repository' => {
            'Module' => {}
        },
        'Logging' => {
            'Report' => {}
        },
        'Environment' => {
            'Program' => {},
            'Setenv'  => {}
        }
    };
    my $cf = &parse_config($t, \&parse_config_callback, $CF);
    sub parse_config_callback {
        my ($CF, $action, $level, $cf) = @_;
        if ($action eq 'CMD' and $cf->[0] =~ m/(Project|Repository|Logging)/) {
            my $a;
            foreach $a (@{$cf->[1]}) {
                $CF->{$1}->{$a->[0]} = $a->[1]
                    if ($a->[0] ne 'Users' and
                        $a->[0] ne 'Groups' and
                        $a->[0] ne 'Modules' and
                        $a->[0] ne 'Reports');
            }
        }
        elsif ($action eq 'CMD' and $cf->[0] eq 'User') {
            $CF->{Project}->{User}->{$cf->[1]} = {
                'name' => $cf->[2],
                'mail' => $cf->[3]
            };
        }
        elsif ($action eq 'CMD' and $cf->[0] eq 'Group') {
            $CF->{Project}->{Group}->{$cf->[1]} = {
                'name' => $cf->[2],
                'users' => $cf->[3]->[0]
            };
        }
        elsif ($action eq 'CMD' and $cf->[0] eq 'Module') {
            $CF->{Repository}->{Module}->{$cf->[1]} = {
                'name' => $cf->[2],
                'acl'  => [],
                'log'  => [],
            };
            my $n = \$CF->{Repository}->{Module}->{$cf->[1]};
            foreach $a (@{$cf->[3]}) {
                if ($a->[0] eq 'Acl') {
                    push(@{${$n}->{acl}}, [ splice(@{$a}, 1) ]);
                }
                elsif ($a->[0] eq 'Log') {
                    push(@{${$n}->{log}}, [ splice(@{$a}, 1) ]);
                }
            }
        }
        elsif ($action eq 'CMD' and $cf->[0] eq 'Report') {
            $CF->{Logging}->{Report}->{$cf->[1]} = {};
            my $n = \$CF->{Logging}->{Report}->{$cf->[1]};
            foreach $a (@{$cf->[2]}) {
                if ($a->[0] eq 'Content') {
                    $$n->{Content} = [ splice(@{$a}, 1) ];
                }
                elsif ($a->[0] =~ m/^(Prefix|Details)$/) {
                    $$n->{$1} = $a->[1];
                }
            }
        }
        elsif ($action eq 'CMD' and $cf->[0] eq 'Program') {
            $CF->{Environment}->{Program}->{$cf->[1]} = $cf->[2];
        }
        elsif ($action eq 'CMD' and $cf->[0] eq 'Setenv') {
            $CF->{Environment}->{Setenv}->{$cf->[1]} = $cf->[2];
        }
        return $cf;
    }
    return $CF;
}

##  _________________________________________________________________
##
##  Determine program command line arguments.
##
##  This is just a poor man's getopt() variant which provides just the
##  functionality we really need. The benefit is that we don't require
##  any extra modules.
##  _________________________________________________________________
##

sub pa_determine {
    my (@ARGV) = @_;
    my $PA = {};

    $PA->{OPT} = {};
    while ($#ARGV >= 0) {
        if ($ARGV[0] =~ m|--([a-zA-Z0-9-]+)$|) {
            $PA->{OPT}->{$1} = 1;
        }
        elsif ($ARGV[0] =~ m|--([a-zA-Z0-9-]+)=(.*)$|) {
            $PA->{OPT}->{$1} = $2;
        }
        else {
            last;
        }
        shift(@ARGV);
    }
    $PA->{ARG} = [ @ARGV ];

    return $PA;
}

##  _________________________________________________________________
##
##  Generalized pattern matching.
##
##  In our configuration file we need patterns. But because in 95% of
##  all cases, simply shell-style patterns are sufficient (and where
##  regular expressions would just complicate the configuration) we
##  need some sort of shell-style wildcard matching. For this if the
##  pattern still isn't a regular expression, we treat the pattern as
##  a shell-style wildcard expression and convert it into a regular
##  expression before matching.
##  _________________________________________________________________
##

sub pattern_match {
    my ($pat, $str) = @_;
    my $rv;

    #   prepare the pattern
    if ($pat =~ m|^m(.)(.+)\1$| and $2 !~ m|$1|) {
        #   pattern is a regular expression,
        #   so just make sure it is anchored
        $pat =~ s|^([^\^])|^$1|;
        $pat =~ s|([^\$])$|$1\$|;
    }
    else {
        #   pattern is not a full regular expression,
        #   so treat it like a weaker shell pattern and
        #   convert it to the regular expression format.
        my $braces = 0;
        my $pat_orig = $pat;
        $pat =~ s@(\\.|\*|.)@
            if    ($1 eq '?') { '[^/]'; }
            elsif ($1 eq '*') { '.*'; }
            elsif ($1 eq '{') { $braces++; '(?:'; }
            elsif ($1 eq '}') { die "Unmatched `}' in `$pat_orig'" unless $braces--; ')'; }
            elsif ($braces > 0 && $1 eq ',') { '|'; }
            elsif (index('()', $1) != -1) { $1; }
            else  { quotemeta(substr($1, -1)); }
        @ges;
        $pat = "^$pat\$";
    }

    #   perform the matching operation
    $rv = ($str =~ m|$pat|s);
    return $rv;
}

##  _________________________________________________________________
##
##  CVS server communication.
##
##  We use this instead of calling the regular CVS client commands
##  because we not always have a working directory available (which is
##  required by most of the CVS client commands), e.g. when an import
##  is done locally (no client/server). So we generally use the CVS
##  client/server protocol to communicate with a spawned CVS server
##  process and act as we would be a regular CVS client. For convenience
##  reasons, the communication is encapsulated in a "CVS" class object.
##  _________________________________________________________________
##

package CVS;

#   communication constructor
sub new {
    my $proto = shift;
    my $class = ref($proto) || $proto;
    my $program = (shift || "cvs");
    my $cvsroot = (shift || $ENV{CVSROOT}) or die "unknown CVSROOT";
    my $trace = shift || 0;

    #   spawn a CVS server process and establish a
    #   bidirectional communication path to it.
    my $cvs = {};
    $cvs->{cvsroot} = $cvsroot;
    $cvs->{trace}   = $trace;
    STDOUT->flush; # because of fork() behind open2()!
    STDERR->flush; # because of fork() behind open2()!
    $cvs->{rfd} = new IO::Handle;
    $cvs->{wfd} = new IO::Handle;
    $cvs->{pid} = IPC::Open2::open2($cvs->{rfd}, $cvs->{wfd}, sprintf("%s -f -Q -n server", &main::qsa($program)))
        or die "cannot spawn CVS server process `$program server'";
    print STDERR "cvs server: spawned (pid $cvs->{pid})\n" if ($trace);
    bless ($cvs, $class);

    #   perform a little bit of common initial operation.
    #   lie a little bit about our capabilities, but if we list
    #   too less responses the CVS server will dislike our request
    $cvs->send(
        "Valid-responses ok error Valid-requests Checked-in New-entry Checksum " .
        "Copy-file Updated Created Update-existing Merged Patched Rcs-diff Mode " .
        "Mod-time Removed Remove-entry Set-static-directory Clear-static-directory " .
        "Set-sticky Clear-sticky Template Set-checkin-prog Set-update-prog Notified " .
        "Module-expansion Wrapper-rcsOption M Mbinary E F");
    $cvs->send("UseUnchanged");
    $cvs->send("Root $cvsroot");
    $cvs->send("noop");
    my $status = $cvs->recv;
    die "unexpected initial CVS server response `$status'" if ($status ne 'ok');

    return $cvs;
}

#   communication destructor
sub DESTROY {
    my $cvs = shift;
    $cvs->close;
    undef $cvs;
    return;
}

#   close communication paths
sub close {
    my $cvs = shift;
    if (defined($cvs->{rfd})) {
        close($cvs->{rfd});
        close($cvs->{wfd});
        waitpid($cvs->{pid}, 0);
        print STDERR "cvs server: closed (pid $cvs->{pid})\n" if ($cvs->{trace});
        $cvs->{rfd} = undef;
        $cvs->{wfd} = undef;
        $cvs->{pid} = undef;
    }
}

#   send one or more commands to the server
sub send {
    my $cvs = shift;
    my $data = join("\n", @_);
    $data .= "\n" if ($data !~ m|\n$|s);
    $cvs->{wfd}->print($data);
    if ($cvs->{trace}) {
        $data =~ s|^|cvs server: -> |mg;
        print STDERR $data;
    }
}

#   recv one or more commands from the server
sub recv {
    my $cvs = shift;
    if (wantarray) {
         my @lines = ($cvs->{rfd}->getlines || ());
         my @nlines = ();
         foreach my $line (@lines) {
             print STDERR "cvs server: <- $line" if ($cvs->{trace});
             $line =~ s|\n$||;
             push(@nlines, $line);
         }
         return @nlines;
    }
    else {
         my $line = ($cvs->{rfd}->getline || "");
         print STDERR "cvs server: <- $line" if ($cvs->{trace});
         $line =~ s|\n$||;
         return $line;
    }
}

#   convenience wrapper: receive a response
sub result {
    my $cvs = shift;
    my $line;
    my $res = '';
    while (($line = $cvs->recv) =~ m/^(M|E) (.*)$/s) {
        $res .= "$2\n" if ($1 eq 'M');
    }
    if (wantarray) {
        return ($res, $line);
    }
    else {
        return $res;
    }
}

#   convenience wrapper: provide a file entry
sub entry {
    my $cvs = shift;
    my @files = @_;
    foreach my $file (@files) {
        $cvs->send("Entry /$file////");
        $cvs->send("Unchanged $file");
    }
}

#   convenience wrapper: provide one or more global options
sub global_options {
    my $cvs = shift;
    my @opts = @_;
    foreach my $opt (@opts) {
        $cvs->send("Global_option $opt");
    }
}

#   convenience wrapper: provide one or more arguments
sub arguments {
    my $cvs = shift;
    my @args = @_;
    foreach my $arg (@args) {
        $cvs->send("Argument $arg");
    }
}

#   convenience wrapper: configure a directory
sub directory {
    my $cvs = shift;
    my ($dir) = @_;
    $cvs->send("Directory .\n".$cvs->{cvsroot}."/".$dir);
    $cvs->send("Static-directory");
}

package main;

##  _________________________________________________________________
##
##  Send out an Electronic Mail.
##
##  Again, there are nice Perl modules which provide mail creation and
##  delivery services, but we both want to be maximum stand-alone and
##  use a KISS solution. So we assume an existing Sendmail program
##  (which is 99% safe, because even non-Sendmail MTAs like Qmail and
##  Postfix provide a Sendmail compatibility frontend!) and deliver the
##  mail directly to it.
##  _________________________________________________________________
##

package Sendmail;

#   communication constructor
sub new {
    my $proto = shift;
    my $class = ref($proto) || $proto;
    my $RT = shift;
    my $toaddr = shift;
    my $trace = shift || 0;

    my $sm = {};
    bless ($sm, $class);
    $sm->{trace} = $trace;
    $sm->{fd} = new IO::Handle;
    my $cmd = sprintf("%s -oi -oem %s", &main::qsa($RT->{sendmail}), &main::qsa($toaddr));
    open($sm->{fd}, "|$cmd");
    print "sendmail: spawned \"$cmd\"\n" if ($sm->{trace});
    $sm->{header} =
        "From: \"".$RT->{username}."\" <".$RT->{usermail}.">\n" .
        "To: $toaddr\n" .
        "User-Agent: OSSP shiela ".$RT->{vers}." [CVS ".$RT->{cvsvers}.($RT->{cvsrse} ? "+RSE" : "")."]\n" .
        "Precedence: bulk\n" .
        "Mime-Version: 1.0\n" .
        "Content-Type: text/plain; charset=iso-8859-1\n" .
        "Content-Transfer-Encoding: 8bit\n";
    $sm->{body} = '';
    return $sm;
}

#   communication destructor
sub DESTROY {
    my $sm = shift;
    $sm->close;
    undef $sm;
    return;
}

#   close communication
sub close {
    my $sm = shift;
    return if (not defined($sm->{body}));
    $sm->{body} =~ s|\n$||s;
    $sm->{body} .= "\n";
    if ($sm->{header} !~ m|^Lines: |m) {
        my $length = length($sm->{body});
        my @lines = split(/\n/, $sm->{body});
        my $lines = $#lines+1;
        $sm->{header} .= sprintf("Lines: %d\n", $lines);
    }
    my $mail = $sm->{header} . "\n" . $sm->{body};
    $sm->{fd}->print($mail);
    if ($sm->{trace}) {
        $mail =~ s|^|sendmail: -> |mg;
        print STDERR $mail;
    }
    $sm->{fd}->close;
    undef $sm->{body};
    print STDERR "sendmail: closed connection\n" if ($sm->{trace});
}

#   set a particular mail header
sub header {
    my $sm = shift;
    my ($name, $value) = @_;
    if ($sm->{header} =~ m|^$name: .*?$|m) {
        $value =~ s|^\s+||s;
        $value =~ s|\s+$||s;
        $sm->{header} =~ s|^$name: .*?$|$name: $value|m;
    }
    else {
        $sm->{header} .= "$name: $value\n";
    }
}

#   set the mail body
sub body {
    my $sm = shift;
    my ($body) = @_;
    $sm->{body} .= $body;
}

package main;

##  _________________________________________________________________
##
##  Common file operations.
##
##  This is nothing more than a convenience function for
##  the common file operations we have do.
##  _________________________________________________________________
##

sub do_file {
    my ($op, $file, $prefix, @lines) = @_;

    #   append to or override a file with lines from an array
    if ($op eq 'append' or $op eq 'write') {
        my $io = new IO::File ($op eq 'append' ? ">>$file" : ">$file")
            or die "unable to open `$file' for operation `$op'";
        foreach my $line (@lines) {
            $line =~ s|\n+$||s;
            $io->print($prefix . $line . "\n");
        }
        $io->close;
    }
    #   read a file line by line into an array
    elsif ($op eq 'read') {
        my @text = ();
        my $io = new IO::File "<$file"
            or die "unable to open `$file' for $op";
        while (<$io>) {
            s|\n$||s;
            push(@text, $prefix . $_);
        }
        $io->close;
        return @text;
    }
}

#   quote shell argument
sub qsa {
    my ($arg) = @_;

    #   remove NUL characters at all because
    #   - sh:   removes silenty      (strange)
    #   - bash: removes silenty      (strange)
    #   - ksh:  complains and aborts (problem)
    #   - zsh:  keeps as-is          (ok)
    #   all(!) other characters in the range 0x00-0xff are safe to be
    #   passed through the shell when single quoted as explicit tests
    #   with all(!) characters under sh, bash, ksh and zsh showed.
    $arg =~ s/\x00//sg;
    
    #   single quote argument by
    #   1. escape "single quote" character by
    #      - temporarily ending single quotation
    #      - double quoting "single quote" character
    #      - restarting single quotation
    #   2. embedding remaining string into single quotes
    $arg =~ s/'/'"'"'/sg;
    $arg = "'$arg'";
    
    return $arg;
}   

##  _________________________________________________________________
##
##  History database support.
##
##  The history database is a logfile to where the commit history is
##  written by us. In short, in summarizes a particular commit and this
##  way can be used later to find out the details of a commit again.
##  _________________________________________________________________
##

sub history_save {
    my ($PA, $RT, $CF, $IN) = @_;
    my $O = '';
    foreach my $file (keys(%{$IN->{file}})) {
        my $e = $IN->{file}->{$file};
        $O .= $IN->{handle};
        $O .= "|".$RT->{userid};
        $O .= "|".$file;
        $O .= "|".$e->{oldrev};
        $O .= "|".$e->{newrev};
        $O .= "|".$e->{branch};
        $O .= "|".$e->{op};
        $O .= "|".$e->{keysub};
        $O .= "|".$e->{date};
        $O .= "|".$e->{delta};
        $O .= "\n";
    }
    my $io = new IO::File ">>".$RT->{historydb}
         or die "cannot store information to history db `$RT->{historydb}'";
    $io->print($O);
    $io->close;
    return;
}

sub history_load {
    my ($PA, $RT, $CF, $handle) = @_;
    ## STILL MISSING, BECAUSE CURRENTLY NOT USED AT ALL.    ##
    ## WOULD HAVE TO RE-DETERMINE DIFF AND LOG INFORMATION. ##
    return;
}

##  _________________________________________________________________
##
##  Provide Access Control.
##
##  This function is called from many hooks to check access control.
##  Whether access is allowed or denied depends entirely on the
##  particular ACL configuration found in the configuration file.
##  _________________________________________________________________
##

sub do_access_control {
    my ($PA, $RT, $CF, @files) = @_;

    my @denyfiles = ();
    my $user = $RT->{userid};
    my @groups = split(/,/, $RT->{usergroups});
    my $file;
    foreach $file (@files) {
        $file =~ m|^([^/]+)/(.*):([^:]+)$|
            or die "invalid file specification `$file' for access control";
        my ($d, $f, $t) = ($1, $2, $3);
        my $allow = 0;
        foreach my $module (keys(%{$CF->{Repository}->{Module}})) {
            if ($module eq $d) {
                my $m = $CF->{Repository}->{Module}->{$module};
                my $acl = $m->{acl};
                foreach my $a (@{$acl}) {
                    my ($file, @require) = @{$a};
                    my $tag = 'HEAD';
                    if ($file =~ m|^(.+):([^:]+)$|) {
                        $file = $1;
                        $tag  = $2;
                    }
                    if (($t eq '*' or &pattern_match($tag,  $t))
                        and &pattern_match($file, $f)) {
                        foreach my $r (@require) {
                            my $not = 0;
                            if ($r =~ m|^!(.+)$|) {
                                $not = 1;
                                $r = $1;
                            }
                            my ($u, $g);
                            if ($r =~ m|^(.+):(.+)$|) {
                                ($u, $g) = ($1, $2);
                            }
                            else {
                                ($u, $g) = ($r, '*');
                            }
                            if (   (    not $not
                                    and ($u eq '*' or $u eq $user)
                                    and ($g eq '*' or grep(m/^$g$/, @groups)))
                                or (    $not
                                    and ($u ne '*' and $u ne $user)
                                    and ($g ne '*' and not grep(m/^$g$/, @groups)))) {
                                $allow = 1;
                                last;
                            }
                        }
                        last;
                    }
                }
                last;
            }
        }
        if (not $allow) {
            push(@denyfiles, $file);
        }
    }
    return @denyfiles;
}

##  _________________________________________________________________
##
##  Compress a log message.
##
##  This compresses a CVS log message by removing unnecessary
##  whitespace, empty fields and CVS lines.
##  _________________________________________________________________
##

sub compress_message {
    my ($msg) = @_;

    #   make sure CVS: lines do not harm anyone
    $msg =~ s/^CVS:.*?$//mg;

    #   remove common empty fields (FIXME: PERHAPS TOO HARD-CODED)
    $msg =~ s/^(PR|Submitted by|Reviewed by|Approved by|Obtained from):\s*$//img;

    #   remove trailing whitespaces
    $msg =~ s/[ \t]+$//mg;

    #   make optically empty lines really empty for next step
    $msg =~ s/^[ \t]+$//mg;

    #   remove unnecessary empty lines
    $msg =~ s/\n{3,}/\n\n/sg;
    $msg =~ s/^\n+//s;
    $msg =~ s/\n{2,}$/\n/s;
    $msg =~ s/([^\n])$/$1\n/s;

    return $msg;
}

##  _________________________________________________________________
##
##  Wrap a single-line log message.
##
##  This line-wraps a single-line log message into a multi-line log
##  message.
##  _________________________________________________________________
##

sub wrap_message {
    my ($columns, $text) = @_;

    my $r = "";
    my $nl = "";
    my $left = "";
    pos($text) = 0;

    while ($text !~ m/\G\s*\Z/gc) {
        if ($text =~ /\G([^\n]{0,$columns})(\s|\z)/xmgc) {
            $r .= $nl . $1;
            $left = $2;
        } elsif ($text =~ /\G([^\n]*?)(\s|\z)/xmgc) {
            $r .= $nl . $1;
            $left = $2;
        }
        $nl = "\n";
    }

    $r .= $left;
    $r .= substr($text, pos($text), length($text)-pos($text))
        if (pos($text) ne length($text));

    return $r;
}

##  _________________________________________________________________
##
##  Fit text into particular columns.
##
##  This makes sure a text fits into a particular columns by
##  truncating (and extending with "$") if necessary.
##  _________________________________________________________________
##

sub fit_columns {
    my ($col, $txt) = @_;
    if (length($txt) > $col) {
        $txt = substr($txt, 0, $col-1) . '$';
    }
    return $txt;
}

##  _________________________________________________________________
##
##  TAGINFO HOOK
##
##  We hook into CVS via `taginfo' to check whether user is allowed to
##  perform tag operation. Additionally we also could check whether the
##  specified tag is a valid tag name.
##
##  We are called by CVS with four or more arguments: the tagname, the
##  operation (`add' for `cvs tag', `mov' for `cvs tag -F', and `del'
##  for `cvs tag -d'), the repository path and one or more file and
##  revisions pairs.
##  _________________________________________________________________
##

sub hook_taginfo {
    my ($PA, $RT, $CF) = @_;
    my $rv = 0;

    #   take the arguments
    my ($tagname, $tagop, $cvsdir, %cvsfiles) = @{$PA->{ARG}};

    #   strip absolute prefix
    $cvsdir =~ s|^$RT->{cvsroot}/?||;
    my $cvsdirphysical = Cwd::abs_path($RT->{cvsroot});
    $cvsdir =~ s|^$cvsdirphysical/?||;

    #   provide access control
    my @paths = ();
    foreach my $cvsfile (keys(%cvsfiles)) {
        push(@paths, "$cvsdir/$cvsfile:*");
    }
    my @denyfiles = &do_access_control($PA, $RT, $CF, @paths);
    if ($#denyfiles > -1) {
        #   inform user
        print "cvs tag: Access Denied - Insufficient Karma!\n";
        print "cvs tag: Tagging access for the following file(s) was denied:\n";
        foreach my $file (@denyfiles) {
            print "cvs tag: `$file'\n";
        }
        print "cvs tag: Contact <".$CF->{Repository}->{Contact}."> for details.\n";

        #   inform administrator
        my $subject = "[CVS] ".$CF->{Project}->{Tag}.": ACCESS DENIED: ".$RT->{username};
        my $message = '';
        $message .= "ATTENTION: ACCESS DENIED\n";
        $message .= "\n";
        $message .= $CF->{Repository}->{Name}. " denied TAGGING access for\n";
        $message .= "user ".$RT->{username}." <".$RT->{usermail}."> on files:\n";
        $message .= "\n";
        foreach my $file (@denyfiles) {
            $message .= "    o   $file\n";
        }
        my $sm = new Sendmail ($RT, $CF->{Repository}->{Contact});
        $sm->header('Subject', $subject);
        $sm->body($message);
        $sm->close;
        $rv = 1;
    }

    return $rv;
}

##  _________________________________________________________________
##
##  ADMININFO HOOK
##
##  We hook into CVS via `admininfo' to check whether user is allowed to
##  perform admin operations.
##
##  We are called by CVS with two or more arguments: the (absolute)
##  repository directory, followed by one or more names of files in this
##  directory on which the admin operation should be performed.
##  _________________________________________________________________
##

sub hook_admininfo {
    my ($PA, $RT, $CF) = @_;
    my $rv = 0;

    #   take the arguments
    my ($cvsdir, @cvsfiles) = @{$PA->{ARG}};
    $cvsdir =~ s|^$RT->{cvsroot}/?||;

    #   provide access control
    my @paths = ();
    foreach my $cvsfile (@cvsfiles) {
        push(@paths, "$cvsdir/$cvsfile:*");
    }
    my @denyfiles = &do_access_control($PA, $RT, $CF, @paths);
    if ($#denyfiles > -1) {
        #   inform user
        print "cvs admin: Access Denied - Insufficient Karma!\n";
        print "cvs admin: Admin access for the following file(s) was denied:\n";
        foreach my $file (@denyfiles) {
            print "cvs admin: `$file'\n";
        }
        print "cvs admin: Contact <".$CF->{Repository}->{Contact}."> for details.\n";

        #   inform administrator
        my $subject = "[CVS] ".$CF->{Project}->{Tag}.": ACCESS DENIED: ".$RT->{username};
        my $message = '';
        $message .= "ATTENTION: ACCESS DENIED\n";
        $message .= "\n";
        $message .= $CF->{Repository}->{Name}. " denied ADMIN access for\n";
        $message .= "user ".$RT->{username}." <".$RT->{usermail}."> on files:\n";
        $message .= "\n";
        foreach my $file (@denyfiles) {
            $message .= "    o   $file\n";
        }
        my $sm = new Sendmail ($RT, $CF->{Repository}->{Contact});
        $sm->header('Subject', $subject);
        $sm->body($message);
        $sm->close;
        $rv = 1;
    }

    return $rv;
}

##  _________________________________________________________________
##
##  IMPORTINFO HOOK
##
##  We hook into CVS via `importinfo' to check whether user is allowed to
##  perform import operations.
##
##  We are called by CVS with one argument: the (absolute) repository
##  directory into which the import operation should be performed.
##  _________________________________________________________________
##

sub hook_importinfo {
    my ($PA, $RT, $CF) = @_;
    my $rv = 0;

    #   take the arguments
    my ($cvsbranch, $cvsdir, @cvsfiles) = @{$PA->{ARG}};
    $cvsdir =~ s|^$RT->{cvsroot}/?||;

    #   provide access control
    my @paths = ();
    foreach my $cvsfile (@cvsfiles) {
        push(@paths, "$cvsdir/$cvsfile:$cvsbranch");
    }
    my @denyfiles = &do_access_control($PA, $RT, $CF, @paths);
    if ($#denyfiles > -1) {
        #   inform user
        print "cvs import: Access Denied - Insufficient Karma!\n";
        print "cvs import: Import access for the following files was denied:\n";
        foreach my $file (@denyfiles) {
            print "cvs import: `$file'\n";
        }
        print "cvs import: Contact <".$CF->{Repository}->{Contact}."> for details.\n";

        #   inform administrator
        my $subject = "[CVS] ".$CF->{Project}->{Tag}.": ACCESS DENIED: ".$RT->{username};
        my $message = '';
        $message .= "ATTENTION: ACCESS DENIED\n";
        $message .= "\n";
        $message .= $CF->{Repository}->{Name}. " denied IMPORT access for\n";
        $message .= "user ".$RT->{username}." <".$RT->{usermail}."> on files:\n";
        $message .= "\n";
        foreach my $file (@denyfiles) {
            $message .= "    o   $file\n";
        }
        my $sm = new Sendmail ($RT, $CF->{Repository}->{Contact});
        $sm->header('Subject', $subject);
        $sm->body($message);
        $sm->close;
        $rv = 1;
    }

    return $rv;
}

##  _________________________________________________________________
##
##  COMMITINFO HOOK
##
##  We hook into CVS via `commitinfo' to provide repository access
##  control ("is user allowed to commit") and to provide preparations
##  for logging in multi-directory commits. The general problem we have
##  is just that CVS does not provide a single hook where the complete
##  commit message is available. Instead for a single multi-directory
##  commit, we are called multiple times. So in the `loginfo' hook below
##  we have to accumlate all information and do the actual logging at
##  the last call only. For this we need to know which call is the last
##  call. So we use this `commitinfo' hook to determine the last call by
##  remembering the directory of the multi-directory commit.
##
##  We are called by CVS with the absolute path (prefixed with $CVSROOT)
##  to the CVS directory as the first argument, followed by one or more
##  names of files which are comitted in this directory.
##  _________________________________________________________________
##

sub hook_commitinfo {
    my ($PA, $RT, $CF) = @_;
    my $rv = 0;

    #   take the arguments and make the directory relative
    my ($cvsdir, @cvsfiles) = @{$PA->{ARG}};
    $cvsdir =~ s|^$RT->{cvsroot}/?||;

    #   annotate the files with the branch they stay on
    my $cvsstat = '';
    if (not $RT->{useserver}) {
        my $io = new IO::File sprintf("%s -f -Q -n status %s|", &qsa($RT->{cvs}), join(' ', map { &qsa($_) } @cvsfiles))
            or die "unable to open CVS command pipe for reading";
        $cvsstat .= $_ while (<$io>);
        $io->close;
    }
    else {
        my $cvs = new CVS ($RT->{cvs}, $RT->{cvsroot});
        $cvs->global_options("-Q", "-n");
        $cvs->directory($cvsdir);
        foreach my $cvsfile (@cvsfiles) {
            $cvs->entry($cvsfile);
            $cvs->arguments($cvsfile);
        }
        $cvs->send("status");
        $cvsstat .= scalar $cvs->result;
        $cvs->close;
    }
    my @newfiles = ();
    foreach my $cvsfile (@cvsfiles) {
        my $branch = 'HEAD';
        my $cvsfile_quoted = quotemeta($cvsfile);
        if ($cvsstat =~ m|===+\nFile:\s+${cvsfile_quoted}.+?Sticky Tag:\s+(\S+)|s) {
            $branch = $1;
            $branch = 'HEAD' if ($branch eq '(none)');
        }
        $cvsfile .= ":$branch";
        push(@newfiles, $cvsfile);
    }
    @cvsfiles = @newfiles;

    #   provide access control
    my @paths = ();
    foreach my $cvsfile (@cvsfiles) {
        push(@paths, "$cvsdir/$cvsfile");
    }
    my @denyfiles = &do_access_control($PA, $RT, $CF, @paths);
    if ($#denyfiles > -1) {
        #   inform user
        print "cvs commit: Access Denied - Insufficient Karma!\n";
        print "cvs commit: Commit access for the following file(s) was denied:\n";
        foreach my $file (@denyfiles) {
            print "cvs commit: `$file'\n";
        }
        print "cvs commit: Contact <".$CF->{Repository}->{Contact}."> for details.\n";

        #   inform administrator
        my $subject = "[CVS] ".$CF->{Project}->{Tag}.": ACCESS DENIED: ".$RT->{username};
        my $message = '';
        $message .= "ATTENTION: ACCESS DENIED\n";
        $message .= "\n";
        $message .= $CF->{Repository}->{Name}. " denied COMMIT access for\n";
        $message .= "user ".$RT->{username}." <".$RT->{usermail}."> on files:\n";
        $message .= "\n";
        foreach my $file (@denyfiles) {
            $message .= "    o   $file\n";
        }
        my $sm = new Sendmail ($RT, $CF->{Repository}->{Contact});
        $sm->header('Subject', $subject);
        $sm->body($message);
        $sm->close;
        $rv = 1;
    }

    #   remember the (last) directory
    &do_file('write', $RT->{tmpfile}.".lastdir", '', $cvsdir);

    return $rv;
}

##  _________________________________________________________________
##
##  VERIFYMSG HOOK
##
##  We hook into CVS via `verifymsg' to post-process log messages. The
##  intention is to sanitise the results of what the user may have
##  `done' while editing the commit log message. If CVS is an anchient
##  version, this check is advisory only. If CVS is at least version
##  1.11.2, the log message can be changed and CVS actually reads back
##  the contents so that this script can actually make changes.
##
##  We are called by CVS with a single argument: the path to the log
##  message file.
##  _________________________________________________________________
##

sub hook_verifymsg {
    my ($PA, $RT, $CF) = @_;
    my $rv = 0;

    #   suck in the log message
    my $logfile = $PA->{ARG}->[0];
    my $io = new IO::File "<$logfile"
        or die "cannot open message file `$logfile' for reading";
    my $data = '';
    $data .= $_ while (<$io>);
    $io->close;

    #  filter the log message
    $data = &compress_message($data);

    #  update the log message
    #  (CVS with RSE patches reads in this again, stock CVS ignores it)
    $io = new IO::File ">$logfile"
        or die "cannot open message file `$logfile' for writing";
    $io->print($data);
    $io->close;

    #   nuke possibly existing editor backup files
    unlink("${logfile}~");
    unlink("${logfile}.bak");

    return $rv;
}

##  _________________________________________________________________
##
##  LOGINFO HOOK
##
##  We hook into CVS via `loginfo' to provide accumulated commit mails
##  and logfile entries. For this we depend on the `commitinfo' hook,
##  which has to determine the last directory. Only this way we can
##  decide when to accumulate and when to perform the logging.
##
##  We are called by CVS with a single argument which contains the
##  ($CVSROOT relative) directory followed by the summary arguments
##  about the committed files in this directory - all seperated by
##  whitespace. The summary arguments are comma-seperated strings
##  of the form <op>,<file>,
##  _________________________________________________________________
##

sub hook_loginfo {
    my ($PA, $RT, $CF) = @_;
    my $rv = 0;

    #   collect the information of this particular call
    my $cvsdir = &hook_loginfo_collect($PA, $RT, $CF);

    #   determine whether we are the last call
    my $islastcall = ($RT->{cvsop} eq 'import' ? 1 : 0);
    if (-f "$RT->{tmpfile}.lastdir") {
        my ($lastdir) = &do_file('read', "$RT->{tmpfile}.lastdir", '');
        $islastcall = 1 if ($lastdir eq $cvsdir);
    }

    #   stop processing if we are still not the last call
    exit(0) if (not $islastcall);

    #   cleanup
    unlink("$RT->{tmpfile}.lastdir");

    #   accumulate the gathered information
    my $IN = &hook_loginfo_accumulate($PA, $RT, $CF);

    #   DEBUGGING
    if ($PA->{OPT}->{debug}) {
        print STDOUT "| \$PA =\n" . Data::Dumper::Dumper($PA);
        print STDOUT "| \$CF =\n" . Data::Dumper::Dumper($CF);
        print STDOUT "| \$RT =\n" . Data::Dumper::Dumper($RT);
        print STDOUT "| \$IN =\n" . Data::Dumper::Dumper($IN);
    }

    #   remember the information (partly) in our history database
    #   for use by foreign application calls.
    &history_save($PA, $RT, $CF, $IN);

    #   process the collected information
    &hook_loginfo_process($PA, $RT, $CF, $IN);

    return $rv;
}

#   collect the information
sub hook_loginfo_collect {
    my ($PA, $RT, $CF) = @_;

    #   take the arguments
    my $cvsdir = $PA->{ARG}->[0];
    my @cvsinfo = ();
    my $k = ($RT->{cvsrse} ? 5 : 3);
    for (my $i = 1; $i <= $#{$PA->{ARG}}; $i += $k) {
        push(@cvsinfo, join(",", @{$PA->{ARG}}[$i..$i+$k-1]));
    }

    #   suck in the standard log information which CVS provides
    my $cvsmsg = '';
    $cvsmsg .= $_ while (<STDIN>);

    #   usually the operation is a regular commit for files
    $RT->{cvsop} = 'commit-file';

    #   handle special invocation under `cvs add <dir>'
    if (defined($PA->{ARG}->[1]) and $PA->{ARG}->[1] eq '- New directory') { # see CVS' src/add.c
        #   Hmmm... we always just deal with files in OSSP shiela, so there
        #   is no obvious and consistent way to deal now with only a
        #   plain directory. And there is also no log message provided
        #   by CVS. Additionally, creating empty directories in the CVS
        #   repository doesn't harm anyone. A regular cronjob is usually
        #   used to get rid of them anyway. So we decided to not log
        #   `cvs add <dir>' commands at all. We are early in processing
        #   it is acceptable to just exit OSSP shiela immediately.
        exit(0);
    }

    #   handle special invocation under `cvs import <dir>'. Here
    #   CVS only calls us inside the loginfo hook and never in the
    #   commitinfo hook before. Additionally CVS doesn't provide us with
    #   the %{sVvto} information :(
    if (defined($PA->{ARG}->[1]) and $PA->{ARG}->[1] eq '- Imported sources') { # see CVS' src/import.c
        #   I = ignored
        #   L = link (=error),
        #   N = new file
        #   U = updated w/o conflict
        #   C = updated w/  conflict
        #   T = touched/tagged only (RSE extension)
        $RT->{cvsop} = 'import';
        @cvsinfo = ();
        $cvsmsg =~ s|Status:\n+Vendor Tag:\s+(\S+).*?\nRelease Tags:\s+(.+?)\s*\n(.+)$||s;
        my ($It, $IT, $list) = ($1, $2, $3);
        $cvsmsg .= sprintf("[Release Tag%s: %s]\n", ($IT =~ m|\s| ? 's' : ''), $IT);
        while ($list =~ s|\n([ILNUCT])\s+(\S+)||s) {
            my ($Io, $Is) = ($1, $2);

            #   canonicalize information
            $Is =~ s|^$cvsdir/?||;
            if ($Io eq 'I' or $Io eq 'L') { next; }
            elsif ($Io eq 'N') { $Io = 'A'; }
            elsif ($Io eq 'U' or $Io eq 'C') { $Io = 'M'; }
            elsif ($Io eq 'T') { $Io = 'T'; }

            #   determine revisions
            my $rcslog = '';
            if (not $RT->{useserver}) {
                if (not -d './CVS') {
                    #  Oooopps, the user is doing a local import (no
                    #  client server usage), or else CVS would have
                    #  provided a temporary working area on the server
                    #  side for us. Now we can only hope the CVS version
                    #  is at least capable of server communications...
                    print STDERR "cvs import: Warning: OSSP shiela cannot process local imports\n";
                    print STDERR "cvs import: if the CVS version isn't at least capable of\n";
                    print STDERR "cvs import: server communications (which we're forced to use).\n";
                    print STDERR "cvs import: Ignoring this operation - don't expect log messages!\n";
                    exit(0);
                }
                my $io = new IO::File sprintf("%s -f -Q -n log -r%s %s|", &qsa($RT->{cvs}), &qsa($It), &qsa($Is))
                    or die "unable to open CVS command pipe for reading";
                $rcslog = $_ while (<$io>);
                $io->close;
            }
            else {
                my $cvs = new CVS ($RT->{cvs}, $RT->{cvsroot});
                my ($subdir, $file) = ($cvsdir, $Is);
                if ($file =~ m|^(.+)/([^/]+)$|) {
                    ($subdir, $file) = ($subdir."/".$1, $2);
                }
                $cvs->directory($subdir);
                $cvs->entry($file);
                $cvs->arguments("-r$It", $file);
                $cvs->send("log");
                $rcslog = scalar $cvs->result;
                $cvs->close;
            }
            my ($IV, $Iv) = ($It, $It);
            if ($Io eq 'A') {
                if ($rcslog =~ m|^.*?\nrevision\s+([0-9.]+)|s) {
                    ($IV, $Iv) = ('NONE', $1);
                }
            }
            elsif ($Io eq 'M') {
                if ($rcslog =~ m|^.*?\nrevision\s+([0-9.]+).*?\nrevision\s+([0-9.]+)|s) {
                    ($IV, $Iv) = ($2, $1);
                }
            }
            elsif ($Io eq 'T') {
                ($IV, $Iv) = ('NONE', 'NONE');
            }
            my $entry = "$Is,$IV,$Iv,$It,$Io";
            push(@cvsinfo, $entry);
        }
    }

    #   parse out log description from provided CVS log information and
    #   strip leading and trailing blank lines from the log message.
    #   Also compress multiple blank lines in the body of the message
    #   down to a single blank line.
    my $cvslog = $cvsmsg;
    $cvslog =~ s|.*Log Message:\s*\n(.+)$|$1|s;
    $cvslog = &compress_message($cvslog);
    $cvslog = "*** empty log message ***" if ($cvslog eq '');
    &do_file('write', "$RT->{tmpfile}.log", '', $cvslog);

    #   if we are using a stock CVS version, we have to determine
    #   extra information (which an RSE CVS version would provide).
    if (    (   (    defined($cvsinfo[0])
                 and $cvsinfo[0] =~ m|^([^,]+),([^,]+),([^,]+)$|)
             or not $RT->{cvsrse}                                )
        and not $RT->{cvsop} eq 'import'                          ) {

        #   parse CVS commit information
        my $tag = 'HEAD';
        my $line;
        my $state = '-';
        my $files = {};
        foreach $line (split(/\n/, $cvsmsg)) {
            $line =~ s/[ \t\n]+$//;
            if ($line =~ /^Revision\/Branch:\s*(.+)$/) {
                $tag = $1;
                next;
            }
            if ($line =~ m/^[ \t]+Tag:\s*(.+)$/) {
                $tag = $1;
                next;
            }
            if ($line =~ m/^[ \t]+No tag$/) {
                $tag = 'HEAD';
                next;
            }
            if ($line =~ m/^Added Files/)    { $state = 'A'; next; }
            if ($line =~ m/^Modified Files/) { $state = 'M'; next; }
            if ($line =~ m/^Removed Files/)  { $state = 'R'; next; }
            if ($line =~ m/^Log Message/)    { $state = '-'; next; }

            if ($state =~ m/^[AMR]$/) {
                my $file;
                foreach $file (split(/\s+/, $line)) {
                    $files->{$file} = "$tag,$state";
                }
            }
        }

        #   extend the CVS summary of each file
        my @newinfo = ();
        foreach my $info (@cvsinfo) {
            $info =~ m|^([^,]+),([^,]+),([^,]+)|
                or die "invalid loginfo argument `$info' while extending stock CVS information";
            my ($Is, $IV, $Iv) = ($1, $2, $3);

            my $It = '';
            my $Io = '';
            if ($files->{$Is} =~ m|^([^,]*),([^,]*)$|) {
                ($It, $Io) = ($1, $2);
            }

            $info = "$Is,$IV,$Iv,$It,$Io";
            push(@newinfo, $info);
        }
        @cvsinfo = @newinfo;
    }

    #   extend summary information
    my $cvsdiff = '';
    my @newinfo = ();
    foreach my $info (@cvsinfo) {
        $info =~ m|^([^,]+),([^,]+),([^,]+),([^,]*),([^,]*)$|
            or die "invalid loginfo argument `$info' while extending summary information";
        my ($Is, $IV, $Iv, $It, $Io) = ($1, $2, $3, $4, $5);

        #   fix branch/tag and accumulate information
        $It = 'HEAD' if ($It eq '');

        #   manually determine next revision number for removed files
        #   by fetching the whole revision log and extracting the next
        #   number.
        if ($Io eq 'R' and $Iv eq 'NONE') {
            my $rcslog ='';
            if (not $RT->{useserver}) {
                my $io = new IO::File sprintf("%s -f -Q -n log %s|", &qsa($RT->{cvs}), &qsa($Is))
                    or die "unable to open CVS command pipe for reading";
                $rcslog .= $_ while (<$io>);
                $io->close;
            }
            else {
                my $cvs = new CVS ($RT->{cvs}, $RT->{cvsroot});
                $cvs->directory($cvsdir);
                $cvs->entry($Is);
                $cvs->arguments($Is);
                $cvs->send("log");
                $rcslog = scalar $cvs->result;
                $cvs->close;
            }
            if ($rcslog =~ m|^head:\s+([\d.]+)|m) {
                $Iv = $1;
            }
        }

        #   read file log entry
        my $rcslog = '';
        if ($Io eq 'A' or $Io eq 'M' or $Io eq 'R') {
            if (not $RT->{useserver}) {
                my $io = new IO::File sprintf("%s -f -Q -n log -r%s %s|", &qsa($RT->{cvs}), &qsa($Iv), &qsa($Is))
                    or die "unable to open CVS command pipe for reading";
                $rcslog .= $_ while (<$io>);
                $io->close;
            }
            else {
                my $cvs = new CVS ($RT->{cvs}, $RT->{cvsroot});
                $cvs->directory($cvsdir);
                $cvs->entry($Is);
                $cvs->arguments("-r$Iv", $Is);
                $cvs->send("log");
                $rcslog = scalar $cvs->result;
                $cvs->close;
            }
        }

        #   determine keyword substitutions
        my $Ik = 'kv';
        if ($rcslog =~ m|keyword\s+substitution:\s+(\S+)|s) {
            $Ik = $1;
        }

        #   determine commit date
        my $ID = 0;
        if ($rcslog =~ m|\ndate:\s+(\d\d\d\d)[/-](\d\d)[/-](\d\d)\s+(\d\d):(\d\d):(\d\d)(?:\s+[+-]?\d+)?;|s) {
            my ($Y,$M,$D,$h,$m,$s) = ($1,$2,$3,$4,$5,$6);
            $ID = POSIX::mktime($s, $m, $h, $D, $M-1, $Y-1900);
        }

        #   determine change delta
        my $Id = '+0/-0';
        if ($Ik eq 'b' or -B $Is) {
            $Id = 'BLOB';
        }
        else {
            if ($Io eq 'A') {
                #   determined later below when we have to read in the
                #   whole content anyway in order to create the difference.
            }
            elsif ($Io eq 'M') {
                if ($rcslog =~ m|\ndate:.*lines:\s*([\d \t+-]+)|s) {
                    $Id = $1;
                    $Id =~ s|\s+|/|g;
                }
            }
            elsif ($Io eq 'R') {
                my $cvs = new CVS ($RT->{cvs}, $RT->{cvsroot});
                $cvs->directory($cvsdir);
                $cvs->entry($Is);
                $cvs->arguments("-p", "-r$IV", $Is);
                $cvs->send("update");
                my $f = scalar $cvs->result;
                $cvs->close;
                my $l = 0;
                $f =~ s|\n|$l++|sge;
                $Id = sprintf("+%d/-%d", 0, $l);
            }
        }

        #   determine change difference summary
        if ($Io eq 'A') {
            ##
            ##   ADDED FILE
            ##

            #   retrieve whole file contents
            unlink("$RT->{tmpfile}.all");
            my $io = new IO::File ">$RT->{tmpfile}.all"
                or die "unable to open temporary file $RT->{tmpfile}.all for writing";
            my $l = 0;
            if (not $RT->{useserver}) {
                my $cvs = new IO::File sprintf("%s -f -Q -n update -p -r%s %s|", &qsa($RT->{cvs}), &qsa($Iv), &qsa($Is))
                    or die "unable to open CVS command pipe for reading";
                while (<$cvs>) {
                    $io->print($_);
                    $l++;
                }
                $cvs->close;
            }
            else {
                my $cvs = new CVS ($RT->{cvs}, $RT->{cvsroot});
                $cvs->directory($cvsdir);
                $cvs->entry($Is);
                $cvs->arguments("-p", "-r$Iv", $Is);
                $cvs->send("update");
                my $r = scalar $cvs->result;
                $io->print($r);
                $cvs->close;
                if ($r ne '') {
                    $l++ while ($r =~ m/^/mg);
                }
            }
            $Id = sprintf("+%d/-%d", $l, 0) if (not ($Ik eq 'b' or -B $Is));
            $io->close;

            if ($Ik eq 'b' or -B $Is) {
                #   generate binary change patch script
                if ($RT->{xdelta} and $RT->{uuencode}) {
                    $cvsdiff .=
                        "<shiela:patch $cvsdir/$Is>\n" .
                        "(cd $cvsdir && \\\n" .
                        " uudecode <<'@@ .' && \\\n" .
                        " xdelta patch $Is.xdelta /dev/null $Is && \\\n" .
                        " rm -f $Is.xdelta)\n" .
                        "Index: $cvsdir/$Is\n" .
                        ("=" x 76) . "\n";
                    unlink("$RT->{tmpfile}.null");
                    unlink("$RT->{tmpfile}.xdelta");
                    my $io = new IO::File ">$RT->{tmpfile}.null"
                        or die "unable to open temporary file $RT->{tmpfile}.null for writing";
                    $io->close;
                    system(sprintf("%s delta %s.null %s.all %s.xdelta >/dev/null 2>&1",
                           &qsa($RT->{xdelta}), &qsa($RT->{tmpfile}), &qsa($RT->{tmpfile}), &qsa($RT->{tmpfile})));
                    $io = new IO::File sprintf("%s %s.xdelta %s.xdelta|", &qsa($RT->{uuencode}), &qsa($RT->{tmpfile}), &qsa($Is))
                        or die "unable to open uuencode command pipe for reading";
                    $cvsdiff .= $_ while (<$io>);
                    $io->close;
                    $cvsdiff .= "@@ .\n";
                    $cvsdiff .= "</shiela:patch>\n";
                    unlink("$RT->{tmpfile}.null");
                    unlink("$RT->{tmpfile}.xdelta");
                }
            }
            else {
                #   generate textual change patch script
                if ($RT->{diff}) {
                    $cvsdiff .=
                        "<shiela:patch $cvsdir/$Is>\n" .
                        "patch -p0 <<'@@ .'\n" .
                        "Index: $cvsdir/$Is\n" .
                        ("=" x 76) . "\n" .
                        "\$ cvs diff -u -r0 -r$Iv $Is\n";
                    my $diff = '';
                    my $io = new IO::File sprintf("%s -u /dev/null %s.all|", &qsa($RT->{diff}), &qsa($RT->{tmpfile}))
                        or die "unable to open CVS command pipe for reading";
                    $diff .= $_ while (<$io>);
                    $io->close;
                    my $Is_quoted = quotemeta("$RT->{tmpfile}.all");
                    $diff =~ s|^(\+\+\+\s+)$Is_quoted|$1$Is|m;
                    $cvsdiff .= $diff;
                    $cvsdiff .= "@@ .\n";
                    $cvsdiff .= "</shiela:patch>\n";
                }
            }

            #   cleanup
            unlink("$RT->{tmpfile}.all");
        }
        elsif ($Io eq 'M') {
            ##
            ##   MODIFIED FILE
            ##

            if ($Ik eq 'b' or -B $Is) {
                #   generate binary change patch script

                if ($RT->{xdelta} and $RT->{uuencode}) {
                    #   retrieve whole file contents (old revision)
                    unlink("$RT->{tmpfile}.old");
                    my $io = new IO::File ">$RT->{tmpfile}.old"
                        or die "unable to open temporary file $RT->{tmpfile}.old for writing";
                    if (not $RT->{useserver}) {
                        my $cvs = new IO::File sprintf("%s -f -Q -n update -p -r%s %s|", &qsa($RT->{cvs}), &qsa($IV), &qsa($Is))
                            or die "unable to open CVS command pipe for reading";
                        $io->print($_) while (<$cvs>);
                        $cvs->close;
                    }
                    else {
                        my $cvs = new CVS ($RT->{cvs}, $RT->{cvsroot});
                        $cvs->directory($cvsdir);
                        $cvs->entry($Is);
                        $cvs->arguments("-p", "-r$IV", $Is);
                        $cvs->send("update");
                        $io->print(scalar $cvs->result);
                        $cvs->close;
                    }
                    $io->close;

                    #   retrieve whole file contents (new revision)
                    unlink("$RT->{tmpfile}.new");
                    $io = new IO::File ">$RT->{tmpfile}.new"
                        or die "unable to open temporary file $RT->{tmpfile}.new for writing";
                    if (not $RT->{useserver}) {
                        my $cvs = new IO::File sprintf("%s -f -Q -n update -p -r%s %s|", &qsa($RT->{cvs}), &qsa($Iv), &qsa($Is))
                            or die "unable to open CVS command pipe for reading";
                        $io->print($_) while (<$cvs>);
                        $cvs->close;
                    }
                    else {
                        my $cvs = new CVS ($RT->{cvs}, $RT->{cvsroot});
                        $cvs->directory($cvsdir);
                        $cvs->entry($Is);
                        $cvs->arguments("-p", "-r$Iv", $Is);
                        $cvs->send("update");
                        $io->print(scalar $cvs->result);
                        $cvs->close;
                    }
                    $io->close;

                    #   generate change patch script
                    $cvsdiff .=
                        "<shiela:patch $cvsdir/$Is>\n" .
                        "(cd $cvsdir && \\\n" .
                        " uudecode <<'@@ .' && \\\n" .
                        " mv $Is $Is.orig && \\\n" .
                        " xdelta patch $Is.xdelta $Is.orig $Is && \\\n" .
                        " rm -f $Is.orig $Is.xdelta)\n" .
                        "Index: $cvsdir/$Is\n" .
                        ("=" x 76) . "\n";
                    unlink("$RT->{tmpfile}.xdelta");
                    system(sprintf("%s delta %s.old %s.new %s.xdelta >/dev/null 2>&1",
                        &qsa($RT->{xdelta}), &qsa($RT->{tmpfile}), &qsa($RT->{tmpfile})));
                    $io = new IO::File sprintf("%s %s.xdelta %s.xdelta|", &qsa($RT->{uuencode}), &qsa($RT->{tmpfile}), &qsa($Is))
                        or die "unable to open uuencode command pipe for reading";
                    $cvsdiff .= $_ while (<$io>);
                    $io->close;
                    $cvsdiff .= "@@ .\n";
                    $cvsdiff .= "</shiela:patch>\n";
                    unlink("$RT->{tmpfile}.xdelta");

                    #   cleanup
                    unlink("$RT->{tmpfile}.old");
                    unlink("$RT->{tmpfile}.new");
                }
            }
            else {
                #   generate textual change patch script
                my $d = '';
                if (not $RT->{useserver}) {
                    my $io = new IO::File sprintf("%s -f -Q -n diff -u -r%s -r%s %s|", &qsa($RT->{cvs}), &qsa($IV), &qsa($Iv), &qsa($Is))
                        or die "unable to open CVS command pipe for reading";
                    $d .= $_ while (<$io>);
                    $io->close;
                }
                else {
                    my $cvs = new CVS ($RT->{cvs}, $RT->{cvsroot});
                    $cvs->directory($cvsdir);
                    $cvs->entry($Is);
                    $cvs->arguments("-u", "-r$IV", "-r$Iv", $Is);
                    $cvs->send("diff");
                    $d .= scalar $cvs->result;
                    $cvs->close;
                }
                my $Is_quoted = quotemeta($Is);
                $d =~ s|^Index:.+?\ndiff\s+.*?\n||s;
                $d =~ s|^(---\s+)${Is_quoted}(\s+)|$1$cvsdir/$Is$2|m;
                $d =~ s|^(\+\+\+\s+)${Is_quoted}(\s+)|$1$cvsdir/$Is$2|m;
                $cvsdiff .=
                    "<shiela:patch $cvsdir/$Is>\n" .
                    "patch -p0 <<'@@ .'\n" .
                    "Index: $cvsdir/$Is\n" .
                    ("=" x 76) . "\n" .
                    "\$ cvs diff -u -r$IV -r$Iv $Is\n" .
                    $d .
                    "@@ .\n" .
                    "</shiela:patch>\n";
            }
        }
        elsif ($Io eq 'R') {
            ##
            ##   REMOVED FILE
            ##

            #   generate binary and textaual change patch script
            $cvsdiff .=
                "<shiela:patch $cvsdir/$Is>\n" .
                "rm -f $cvsdir/$Is <<'@@ .'\n" .
                "Index: $cvsdir/$Is\n" .
                ("=" x 76) . "\n" .
                "[NO CHANGE SUMMARY BECAUSE FILE AS A WHOLE IS JUST REMOVED]\n" .
                "@@ .\n" .
                "</shiela:patch>\n";
        }

        $info = "$cvsdir/$Is,$IV,$Iv,$It,$Io,$Ik,$ID,$Id";
        push(@newinfo, $info);
    }
    @cvsinfo = @newinfo;

    #   determine the temporary storage
    my $storage;
    for (my $i = 0; ; $i++) {
        $storage = "$RT->{tmpfile}.$i";
        last if (not -e "$storage.info");
        #my @text = &file_read($storage, '');
        #last if ($#text == -1);
        #last if ($cvslogmsg eq join("\n", @text));
    }

    #   store the information gathered in this pass
    &do_file('write', "$storage.info", '', @cvsinfo);
    &do_file('write', "$storage.diff", '', $cvsdiff);

    return $cvsdir;
}

#   accumulate the collected information
sub hook_loginfo_accumulate {
    my ($PA, $RT, $CF) = @_;

    #   lumb together all information we remembered until now
    my $cvslog = join("\n", &do_file('read', "$RT->{tmpfile}.log", ''))."\n";
    unlink("$RT->{tmpfile}.log");
    my @cvsinfo = ();
    my $cvsdiff = '';
    for (my $i = 0; ; $i++) {
        my $storage = "$RT->{tmpfile}.$i";
        last if (not -e "$storage.info");
        push(@cvsinfo, &do_file('read', "$storage.info", ''));
        $cvsdiff .= join("\n", &do_file('read', "$storage.diff", ''))."\n";
        unlink("$storage.info");
        unlink("$storage.diff");
    }

    #   parse information into internal structure
    my $IN = {
        'file' => {},
        'handle' => '',
        'log' => $cvslog
    };
    $cvsdiff = "\n$cvsdiff\n"; # for easier parsing
    my $handle_min; $handle_min = undef;
    my $handle_max; $handle_max = undef;
    foreach my $cvsinfo (@cvsinfo) {
        $cvsinfo =~ m|^([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),([^,]+)$|
             or die "invalid loginfo argument `$cvsinfo' while accumulating information";
        my ($Is, $IV, $Iv, $It, $Io, $Ik, $ID, $Id) = ($1, $2, $3, $4, $5, $6, $7, $8, $9);
        my $e = {};
        $e->{oldrev} = $IV;
        $e->{newrev} = $Iv;
        $e->{branch} = $It;
        $e->{op}     = $Io;
        $e->{keysub} = $Ik;
        $e->{date}   = $ID;
        $e->{delta}  = $Id;
        $e->{diff} = '';
        my $Is_quoted = quotemeta($Is);
        $cvsdiff =~ s|\n<shiela:patch\s+${Is_quoted}>\n(.+?\n)</shiela:patch>|$e->{diff} = $1, ''|se;
        $IN->{file}->{$Is} = $e;
        $handle_min = $ID if ($ID ne '' and $ID ne '0' and (not defined($handle_min) or $handle_min > $ID));
        $handle_max = $ID if ($ID ne '' and $ID ne '0' and (not defined($handle_max) or $handle_max < $ID));
    }
    $IN->{handle} = '-NONE-';
    if (defined($handle_min) and defined($handle_max)) {
        my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime($handle_min);
        $IN->{handle} = sprintf("%04d%02d%02d%02d%02d%02d%02d",
                                1900+$year, $mon+1, $mday, $hour, $min, $sec,
                                $handle_max - $handle_min);
    }
    return $IN;
}

#   process the accumulated information
sub hook_loginfo_process {
    my ($PA, $RT, $CF, $IN) = @_;

    #   determine log locations and corresponding files
    my $LG = {};
    my $file;
    foreach $file (sort(keys(%{$IN->{file}}))) {
        my ($d, $f) = ($file =~ m|^([^/]+)/(.+)$|);
        my $t = $IN->{file}->{$file}->{branch};
        foreach my $module (keys(%{$CF->{Repository}->{Module}})) {
            if ($module eq $d) {
                my $m = $CF->{Repository}->{Module}->{$module};
                foreach my $log (@{$m->{log}}) {
                    my ($file, @logloc) = @{$log};
                    my $tag = 'HEAD';
                    if ($file =~ m|^(.+):([^:]+)$|) {
                        $file = $1;
                        $tag  = $2;
                    }
                    if (    &pattern_match($tag,  $t)
                        and &pattern_match($file, $f)) {
                        foreach my $logloc (@logloc) {
                            $LG->{$logloc} = [] if (not defined($LG->{$logloc}));
                            push(@{$LG->{$logloc}}, $file);
                        }
                    }
                }
            }
        }
    }

    #   perform one or more logging operations
    foreach my $logloc (sort(keys(%{$LG}))) {
        next if ($logloc eq 'none');
        my @files = @{$LG->{$logloc}};
        if ($logloc =~ m|^([^:]+):(.+)$|) {
            my ($logtype, $logurl) = ($1, $2);
            if ($logurl =~ m|^.+@.+$|) {
                #   send log message as Email
                my $logmsg = &produce_log_message($PA, $RT, $CF, $IN, $logtype, @files);
                my $subject = "[CVS]";
                $subject .= " ".$CF->{Project}->{Tag}.":";
                my $dirlast = '';
                my $branchlast = '';
                foreach my $path (sort(keys(%{$IN->{file}}))) {
                    my ($dir, $file) = ($path =~ m|^(.+)/([^/]+)$|);
                    my $branch = $IN->{file}->{$path}->{branch} || 'HEAD';
                    if ($branchlast ne $branch) {
                        #   prefix with branch
                        $branchlast = $branch;
                        $subject .= " $branch:" if ($branch ne 'HEAD');
                    }
                    if ($dirlast ne $dir) {
                        #   prefix with directory
                        $dirlast = $dir;
                        $subject .= " $dir/";
                    }
                    $subject .= " $file";
                }
                $subject = substr($subject, 0, 70)."..." if (length($subject) > 70);
                print "cvs commit: Mailing commit message to <$logurl>\n";
                my $sm = new Sendmail ($RT, $logurl);
                $sm->header('Subject', $subject);
                if (defined($CF->{Logging}->{Report}->{$logtype}->{Details})) {
                    if ($CF->{Logging}->{Report}->{$logtype}->{Details} eq 'patch:mime') {
                        $sm->header('Content-Type',
                                    "multipart/mixed; boundary=\"".$RT->{mimeboundary}."\"");
                    }
                }
                $sm->body($logmsg);
                $sm->close;
            }
            else {
                #   append log message to file
                my $logmsg = &produce_log_message($PA, $RT, $CF, $IN, $logtype, @files);
                $logurl = $RT->{cvsroot}."/".$logurl if ($logurl !~ m|^/|);
                print "cvs commit: Writing commit message to $logurl\n";
                my $io = new IO::File ">>$logurl" or die "cannot append log message to `$logurl'";
                $io->print($logmsg);
                $io->close;
            }
        }
    }
}

#   produce a particular log messages
sub produce_log_message {
    my ($PA, $RT, $CF, $IN, $type, @files) = @_;

    #
    #   Parse out more details.
    #
    my $cvslist = {};
    my %cvsmodules = ();
    my %cvsbranches = ();
    my $file;
    foreach $file (sort(keys(%{$IN->{file}}))) {
        my $e = $IN->{file}->{$file};
        my ($d, $f) = ($file =~ m|^(.+)/([^/]+)$|);

        #   build lists
        $cvslist->{$e->{op}} = {} if (not defined($cvslist->{$e->{op}}));
        $cvslist->{$e->{op}}->{$e->{branch}} = {} if (not defined($cvslist->{$e->{op}}->{$e->{branch}}));
        $cvslist->{$e->{op}}->{$e->{branch}}->{$d} = [] if (not defined($cvslist->{$e->{op}}->{$e->{branch}}->{$d}));
        push(@{$cvslist->{$e->{op}}->{$e->{branch}}->{$d}}, $f);

        #   accumulate modules
        ($d, $f) = ($file =~ m|^([^/]+)/(.+)$|);
        foreach my $m (sort(keys(%{$CF->{Repository}->{Module}}))) {
            if ($m eq $d) {
                $cvsmodules{$m} = 0 if (not defined($cvsmodules{$m}));
                $cvsmodules{$m}++;
            }
        }

        #   accumulate branches
        $cvsbranches{$e->{branch}} = 0 if (not defined($cvsbranches{$e->{branch}}));
        $cvsbranches{$e->{branch}}++;
    }
    $IN->{cvsbranch} = join(' ', keys(%cvsbranches));
    $IN->{cvsmodule} = join(' ', keys(%cvsmodules));

    #
    #   Finally generate the logging message.
    #

    my $RP = $CF->{Logging}->{Report}->{$type} or die "No report of type `$type' defined";
    my $prefix = $RP->{Prefix} || '';
    my $style = $RP->{Details} || 'patch:plain';
    my $O = '';
    foreach my $content (@{$RP->{Content}}) {

        #   the title
        if ($content eq 'title') {
            $O .= "\n" .
                  $prefix . $CF->{Repository}->{Name} . "\n" .
                  $prefix . $CF->{Repository}->{Home} . "\n";
        }

        #   a rule
        elsif ($content eq 'rule') {
            $O .= $prefix . ("_" x 76) . "\n";
        }

        #   the header lines
        elsif ($content eq 'header') {
            my @moy = ( 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
                        'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' );
            my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time());
            my $txt_date = sprintf("%02d-%s-%04d %02d:%02d:%02d",
                                   $mday, $moy[$mon], 1900+$year, $hour, $min, $sec);
            my $txt_server = &fit_columns(32, $CF->{Repository}->{Host});
            my $txt_root   = &fit_columns(32, $CF->{Repository}->{Path});
            my $txt_module = &fit_columns(32, $IN->{cvsmodule});
            my $txt_branch = &fit_columns(32, $IN->{cvsbranch});
            my $txt_name   = &fit_columns(32, $RT->{username});
            my $txt_email  = &fit_columns(32, $RT->{usermail});
            my $txt_handle = &fit_columns(32, $IN->{handle});
            $O .= "\n" .
                $prefix . sprintf("%-40s %s\n", "Server: ".$txt_server, "Name:   ".$txt_name) .
                $prefix . sprintf("%-40s %s\n", "Root:   ".$txt_root,   "Email:  ".$txt_email) .
                $prefix . sprintf("%-40s %s\n", "Module: ".$txt_module, "Date:   ".$txt_date) .
                $prefix . sprintf("%-40s %s\n", "Branch: ".$txt_branch, "Handle: ".$txt_handle);
        }

        #   the file list
        elsif ($content eq 'files') {
            $O .= "\n";
            $O .= &format_op($prefix, "Imported files", $cvslist->{I}) if (defined($cvslist->{I}));
            $O .= &format_op($prefix, "Added files",    $cvslist->{A}) if (defined($cvslist->{A}));
            $O .= &format_op($prefix, "Modified files", $cvslist->{M}) if (defined($cvslist->{M}));
            $O .= &format_op($prefix, "Touched files",  $cvslist->{T}) if (defined($cvslist->{T}));
            $O .= &format_op($prefix, "Removed files",  $cvslist->{R}) if (defined($cvslist->{R}));
            sub format_op {
                my ($prefix, $header, $list) = @_;
                my $O = '';
                my $branch;
                foreach $branch (sort(keys(%{$list}))) {
                    if ($branch eq 'HEAD') {
                        $O .= sprintf("%s%s\n", $prefix, "$header:");
                    }
                    else {
                        $O .= sprintf("%s%-25s %s\n", $prefix, "$header:", "(Branch: $branch)");
                    }
                    $O .= &format_branch($prefix, $header, $branch, $list->{$branch});
                }
                return $O;
            }
            sub format_branch {
                my ($prefix, $header, $branch, $list) = @_;
                my $O = '';
                my $dir;
                foreach $dir (sort(keys(%{$list}))) {
                    $O .= &format_dir($prefix, $header, $branch, $dir, $list->{$dir});
                }
                return $O;
            }
            sub format_dir {
                my ($prefix, $header, $branch, $dir, $list) = @_;
                my $O = '';
                my $file;
                my $first = 1;
                my $col = 0;
                foreach $file (sort(@{$list})) {
                    if (($col + 1 + length($file)) > 78) {
                        $O .= "\n";
                        $col = 0;
                    }
                    if ($col == 0) {
                        if ($first) {
                            if ((2+length($dir)) > 25) {
                                $O .= sprintf("%s%s\n", $prefix, "  " . $dir);
                                $O .= sprintf("%s%-25s", $prefix, "");
                            }
                            else {
                                $O .= sprintf("%s%-25s", $prefix, "  " . $dir);
                            }
                            $first = 0;
                        }
                        else {
                            $O .= sprintf("%s%-25s", $prefix, "");
                        }
                        $col += length($prefix) + 25;
                    }
                    $O .= " " . $file;
                    $col += 1 + length($file);
                }
                $O .= "\n" if ($O !~ m|\n$|s);
                return $O;
            }
        }

        #   the log message
        elsif ($content eq 'log') {
            $O .= "\n";
            $O .= $prefix."Log:\n";
            my $log = $IN->{log};
            if ($log !~ m|\n.+|s and length($log) > 70) {
                $log = &wrap_message(70, $log);
            }
            $log =~ s|^|${prefix}  |mg;
            $O .= $log;
        }

        #   the change summary
        elsif ($content eq 'summary') {
            $O .= "\n";
            $O .= $prefix."Summary:\n";
            $O .= $prefix."  Revision    Changes     Path\n";
            foreach $file (sort(keys(%{$IN->{file}}))) {
                my ($op, $rev, $delta) = ($IN->{file}->{$file}->{op},
                                          $IN->{file}->{$file}->{newrev},
                                          $IN->{file}->{$file}->{delta});
                next if ($op eq 'T');
                if ($delta =~ m|^(.+)/(.+)$|) {
                    $delta = sprintf("%-3s %-3s", $1, $2);
                }
                $O .= $prefix . sprintf("  %-12s%-12s%s\n", $rev, $delta, $file);
            }
        }

        #   the change details
        elsif ($content eq 'details') {
            $O .= "\n";
            if ($style =~ m|^url:(.+)|) {
                $O .= "Change details:\n";
                my $urlspec = $1;
                foreach $file (sort(keys(%{$IN->{file}}))) {
                    next if ($IN->{file}->{$file}->{op} eq 'T');
                    my $url = $urlspec;
                    $url =~ s|%([sVv])|
                        if ($1 eq 's') { $file; }
                        elsif ($1 eq 'V') { $IN->{file}->{$file}->{oldrev}; }
                        elsif ($1 eq 'v') { $IN->{file}->{$file}->{newrev}; }
                    |gse;
                    $O .= "  $prefix$url\n";
                }
            }
            elsif ($style eq 'rdiff') {
                $O .= "Change details:\n";
                foreach $file (sort(keys(%{$IN->{file}}))) {
                    next if ($IN->{file}->{$file}->{op} eq 'T');
                    if ($IN->{file}->{$file}->{op} eq 'A') {
                        $O .= "  \$ cvs rdiff -u" .
                              " -r0 -r" . $IN->{file}->{$file}->{newrev} .
                              " " . $file .
                              "\n";
                    }
                    else {
                        $O .= "  \$ cvs rdiff -u" .
                              " -r" . $IN->{file}->{$file}->{oldrev} .
                              " -r" . $IN->{file}->{$file}->{newrev} .
                              " " . $file .
                              "\n";
                    }
                }
            }
            elsif ($style eq 'patch:plain') {
                foreach $file (sort(keys(%{$IN->{file}}))) {
                    next if ($IN->{file}->{$file}->{op} eq 'T');
                    my $diff = $IN->{file}->{$file}->{diff};
                    $diff =~ s|^|$prefix|mg;
                    $O .= $diff;
                }
            }
            elsif ($style eq 'patch:mime') {
                foreach $file (sort(keys(%{$IN->{file}}))) {
                    next if ($IN->{file}->{$file}->{op} eq 'T');
                    my $diff = $IN->{file}->{$file}->{diff};
                    $diff =~ s|\n$||s;
                    $diff .= "\n\n";
                    $O .= "--".$RT->{mimeboundary}."\n";
                    $O .= "Content-Type: text/plain; charset=iso-8859-1\n";
                    $O .= "Content-Transfer-Encoding: 8bit\n";
                    $O .= "Content-Description: changes to $file\n";
                    $O .= "Content-Disposition: attachment\n";
                    $O .= "\n";
                    $O .= "$diff";
                }
            }
        }

    }

    #   post-processing of output
    $O =~ s|^\n+||s;
    $O =~ s|\n+$|\n|s;

    #   MIME post-processing
    if ($style eq 'patch:mime') {
        $O = "This is a multi-part message in MIME format.\n" .
             "--".$RT->{mimeboundary}."\n" .
             "Content-Type: text/plain; charset=iso-8859-1\n" .
             "Content-Transfer-Encoding: 8bit\n" .
             "Content-Description: change summary\n" .
             "Content-Disposition: inline\n" .
             "\n" .
             $O .
             "--".$RT->{mimeboundary}."--\n" .
             "\n";
    }

    return $O;
}


CVSTrac 2.0.1