#! /usr/pkg/bin/perl -T
# -*-Perl-*-

# Copyright (C) 1994-2005 The Free Software Foundation, Inc.

# 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 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.

###############################################################################
###############################################################################
###############################################################################
#
# THIS SCRIPT IS PROBABLY BROKEN.  REMOVING THE -T SWITCH ON THE #! LINE ABOVE
# WOULD FIX IT, BUT THIS IS INSECURE.  WE RECOMMEND FIXING THE ERRORS WHICH THE
# -T SWITCH WILL CAUSE PERL TO REPORT BEFORE RUNNING THIS SCRIPT FROM A CVS
# SERVER TRIGGER.  PLEASE SEND PATCHES CONTAINING THE CHANGES YOU FIND
# NECESSARY TO RUN THIS SCRIPT WITH THE TAINT-CHECKING ENABLED BACK TO THE
# <bug-cvs@nongnu.org> MAILING LIST.
#
# For more on general Perl security and taint-checking, please try running the
# `perldoc perlsec' command.
#
###############################################################################
###############################################################################
###############################################################################

=head1 Name

cvs_acls - Access Control List for CVS

=head1 Synopsis

In 'commitinfo':

  repository/path/to/restrict $CVSROOT/CVSROOT/cvs_acls [-d][-u $USER][-f <logfile>]

where:

  -d  turns on debug information
  -u  passes the client-side userId to the cvs_acls script
  -f  specifies an alternate filename for the restrict_log file

In 'cvsacl':

  {allow.*,deny.*} [|user,user,... [|repos,repos,... [|branch,branch,...]]]

where:

  allow|deny - allow: commits are allowed; deny: prohibited
  user          - userId to be allowed or restricted
  repos         - file or directory to be allowed or restricted
  branch        - branch to be allowed or restricted

See below for examples.

=head1 Licensing

cvs_acls - provides access control list functionality for CVS
  
Copyright (c) 2004 by Peter Connolly <peter.connolly@cnet.com>  
All rights reserved.

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 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 program; if not, write to the Free Software  
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

=head1 Description

This script--cvs_acls--is invoked once for each directory within a 
"cvs commit". The set of files being committed for that directory as 
well as the directory itself, are passed to this script.  This script 
checks its 'cvsacl' file to see if any of the files being committed 
are on the 'cvsacl' file's restricted list.  If any of the files are
restricted, then the cvs_acls script passes back an exit code of 1
which disallows the commits for that directory.  

Messages are returned to the committer indicating the file(s) that 
he/she are not allowed to committ.  Additionally, a site-specific 
set of messages (e.g., contact information) can be included in these 
messages.

When a commit is prohibited, log messages are written to a restrict_log
file in $CVSROOT/CVSROOT.  This default file can be redirected to 
another destination.

The script is triggered from the 'commitinfo' file in $CVSROOT/CVSROOT/.

=head1 Enhancements

This section lists the bug fixes and enhancements added to cvs_acls
that make up the current cvs_acls.

=head2 Fixed Bugs

This version attempts to get rid the following bugs from the
original version of cvs_acls:

=over 2

=item *
Multiple entries on an 'cvsacl' line will be matched individually, 
instead of requiring that all commit files *exactly* match all 
'cvsacl' entries. Commiting a file not in the 'cvsacl' list would
allow *all* files (including a restricted file) to be committed.

[IMO, this basically made the original script unuseable for our 
situation since any arbitrary combination of committed files could 
avoid matching the 'cvsacl's entries.]

=item *
Handle specific filename restrictions. cvs_acls didn't restrict
individual files specified in 'cvsacl'.

=item *
Correctly handle multiple, specific filename restrictions

=item *
Prohibit mix of dirs and files on a single 'cvsacl' line
[To simplify the logic and because this would be normal usage.]

=item *
Correctly handle a mixture of branch restrictions within one work
directory

=item *
$CVSROOT existence is checked too late

=item *
Correctly handle the CVSROOT=:local:/... option (useful for 
interactive testing)

=item *
Replacing shoddy "$universal_off" logic 
(Thanks to Karl-Konig Konigsson for pointing this out.)

=back

=head2 Enhancements

=over 2

=item *
Checks modules in the 'cvsacl' file for valid files and directories

=item *
Accurately report restricted entries and their matching patterns

=item *
Simplified and commented overly complex PERL REGEXPs for readability 
and maintainability

=item *
Skip the rest of processing if a mismatch on portion of the 'cvsacl' line

=item *
Get rid of opaque "karma" messages in favor of user-friendly messages
that describe which user, file(s) and branch(es) were disallowed.

=item *
Add optional 'restrict_msg' file for additional, site-specific 
restriction messages.

=item *
Take a "-u" parameter for $USER from commit_prep so that the script
can do restrictions based on the client-side userId rather than the
server-side userId (usually 'cvs').

(See discussion below on "Admin Setup" for more on this point.)

=item *
Added a lot more debug trace 

=item *
Tested these restrictions with concurrent use of pserver and SSH
access to model our transition from pserver to ext access.

=item *
Added logging of restricted commit attempts.
Restricted commits can be sent to a default file:
$CVSROOT/CVSROOT/restrictlog or to one passed to the script
via the -f command parameter.

=back

=head2 ToDoS 

=over 2

=item *
Need to deal with pserver/SSH transition with conflicting umasks?

=item *
Use a CPAN module to handle command parameters.

=item *
Use a CPAN module to clone data structures.

=back

=head1 Version Information

This is not offered as a fix to the original 'cvs_acls' script since it 
differs substantially in goals and methods from the original and there 
are probably a significant number of people out there that still require 
the original version's functionality.

The 'cvsacl' file flags of 'allow' and 'deny' were intentionally 
changed to 'allow' and 'deny' because there are enough differences 
between the original script's behavior and this one's that we wanted to
make sure that users will rethink their 'cvsacl' file formats before
plugging in this newer script.

Please note that there has been very limited cross-platform testing of 
this script!!! (We did not have the time or resources to do exhaustive
cross-platform testing.)

It was developed and tested under Red Hat Linux 9.0 using PERL 5.8.0.
Additionally, it was built and tested under Red Hat Linux 7.3 using 
PERL 5.6.1.

$Id: cvs_acls.in,v 1.11 2005/09/01 13:48:57 dprice Exp $

This version is based on the 1.11.13 version of cvs_acls
peter.connolly@cnet.com (Peter Connolly) 

  Access control lists for CVS.  dgg@ksr.com (David G. Grubbs)
  Branch specific controls added by voisine@bytemobile.com (Aaron Voisine)

=head1 Installation

To use this program, do the following four things:

0. Install PERL, version 5.6.1 or 5.8.0.

1. Admin Setup:

   There are two choices here. 

   a) The first option is to use the $ENV{"USER"}, server-side userId
      (from the third column of your pserver 'passwd' file) as the basis for 
      your restrictions.  In this case, you will (at a minimum) want to set
      up a new "cvsadmin" userId and group on the pserver machine.  
      CVS administrators will then set up their 'passwd' file entries to
      run either as "cvs" (for regular users) or as "cvsadmin" (for power 
      users).  Correspondingly, your 'cvsacl' file will only list 'cvs'
      and 'cvsadmin' as the userIds in the second column.

      Commentary: A potential weakness of this is that the xinetd 
      cvspserver process will need to run as 'root' in order to switch 
      between the 'cvs' and the 'cvsadmin' userIds.  Some sysadmins don't
      like situations like this and may want to chroot the process.
      Talk to them about this point...

   b) The second option is to use the client-side userId as the basis for
      your restrictions.  In this case, all the xinetd cvspserver processes 
      can run as userId 'cvs' and no 'root' userId is required.  If you have
      a 'passwd' file that lists 'cvs' as the effective run-time userId for
      all your users, then no changes to this file are needed.  Your 'cvsacl'
      file will use the individual, client-side userIds in its 2nd column.

      As long as the userIds in pserver's 'passwd' file match those userIds 
      that your Linux server know about, this approach is ideal if you are 
      planning to move from pserver to SSH access at some later point in time.
      Just by switching the CVSROOT var from CVSROOT=:pserver:<userId>... to 
      CVSROOT=:ext:<userId>..., users can switch over to SSH access without
      any other administrative changes.  When all users have switched over to
      SSH, the inherently insecure xinetd cvspserver process can be disabled.
      [http://ximbiot.com/cvs/manual/cvs-1.11.17/cvs_2.html#SEC32]

      :TODO: The only potential glitch with the SSH approach is the possibility 
      that each user can have differing umasks that might interfere with one 
      another, especially during a transition from pserver to SSH.  As noted
      in the ToDo section, this needs a good strategy and set of tests for that 
      yet...

2. Put two lines, as the *only* non-comment lines, in your commitinfo file:

   ALL $CVSROOT/CVSROOT/commit_prep 
   ALL $CVSROOT/CVSROOT/cvs_acls [-d][-u $USER ][-f <logfilename>]

   where "-d" turns on debug trace
         "-u $USER" passes the client-side userId to cvs_acls 
         "-f <logfilename"> overrides the default filename used to log
                            restricted commit attempts.

   (These are handled in the processArgs() subroutine.)

If you are using client-side userIds to restrict access to your 
repository, make sure that they are in this order since the commit_prep 
script is required in order to pass the $USER parameter.

A final note about the repository matching pattern.  The example above
uses "ALL" but note that this means that the cvs_acls script will run
for each and every commit in your repository.  Obviously, in a large
repository this adds up to a lot of overhead that may not be necesary. 
A better strategy is to use a repository pattern that is more specific 
to the areas that you wish to secure.

3. Install this file as $CVSROOT/CVSROOT/cvs_acls and make it executable.

4. Create a file named CVSROOT/cvsacl and optionally add it to
   CVSROOT/checkoutlist and check it in.  See the CVS manual's
   administrative files section about checkoutlist.  Typically:

   $ cvs checkout CVSROOT
   $ cd CVSROOT
   [ create the cvsacl file, include 'commitinfo' line ]
   [ add cvsacl to checkoutlist ]
   $ cvs add cvsacl
   $ cvs commit -m 'Added cvsacl for use with cvs_acls.' cvsacl checkoutlist

Note: The format of the 'cvsacl' file is described in detail immediately 
below but here is an important set up point:

   Make sure to include a line like the following:

     deny||CVSROOT/commitinfo CVSROOT/cvsacl
     allow|cvsadmin|CVSROOT/commitinfo CVSROOT/cvsacl

   that restricts access to commitinfo and cvsacl since this would be one of
   the easiest "end runs" around this ACL approach. ('commitinfo' has the 
   line that executes the cvs_acls script and, of course, all the 
   restrictions are in 'cvsacl'.)

5. (Optional) Create a 'restrict_msg' file in the $CVSROOT/CVSROOT directory.
   Whenever there is a restricted file or dir message, cvs_acls will look 
   for this file and, if it exists, print its contents as part of the 
   commit-denial message.  This gives you a chance to print any site-specific
   information (e.g., who to call, what procedures to look up,...) whenever
   a commit is denied.

=head1 Format of the cvsacl file

The 'cvsacl' file determines whether you may commit files.  It contains lines
read from top to bottom, keeping track of whether a given user, repository
and branch combination is "allowed" or "denied."  The script will assume 
"allowed" on all repository paths until 'allow' and 'deny' rules change 
that default.  

The normal pattern is to specify an 'deny' rule to turn off
access to ALL users, then follow it with a matching 'allow' rule that will 
turn on access for a select set of users.  In the case of multiple rules for
the same user, repository and branch, the last one takes precedence.

Blank lines and lines with only comments are ignored.  Any other lines not 
beginning with "allow" or "deny" are logged to the restrict_log file.

Lines beginning with "allow" or "deny" are assumed to be '|'-separated
triples: (All spaces and tabs are ignored in a line.)

  {allow.*,deny.*} [|user,user,... [|repos,repos,... [|branch,branch,...]]]

   1. String starting with "allow" or "deny".
   2. Optional, comma-separated list of usernames.
   3. Optional, comma-separated list of repository pathnames.
      These are pathnames relative to $CVSROOT.  They can be directories or
      filenames.  A directory name allows or restricts access to all files and
      directories below it. One line can have either directories or filenames
      but not both.
   4. Optional, comma-separated list of branch tags.
      If not specified, all branches are assumed. Use HEAD to reference the
      main branch.

Example:  (Note: No in-line comments.)

   # ----- Make whole repository unavailable.
   deny

   # ----- Except for user "dgg".
   allow|dgg

   # ----- Except when "fred" or "john" commit to the 
   #       module whose repository is "bin/ls"
   allow|fred, john|bin/ls

   # ----- Except when "ed" commits to the "stable" 
   #       branch of the "bin/ls" repository
   allow|ed|/bin/ls|stable

=head1 Program Logic

CVS passes to @ARGV an absolute directory pathname (the repository
appended to your $CVSROOT variable), followed by a list of filenames
within that directory that are to be committed.

The script walks through the 'cvsacl' file looking for matches on 
the username, repository and branch.

A username match is simply the user's name appearing in the second
column of the cvsacl line in a space-or-comma separate list. If
blank, then any user will match.

A repository match:

=over 2

=item *
Each entry in the modules section of the current 'cvsacl' line is 
examined to see if it is a dir or a file. The line must have 
either files or dirs, but not both. (To simplify the logic.)

=item *
If neither, then assume the 'cvsacl' file was set up in error and
skip that 'allow' line.

=item *
If a dir, then each dir pattern is matched separately against the 
beginning of each of the committed files in @ARGV. 

=item *
If a file, then each file pattern is matched exactly against each
of the files to be committed in @ARGV.

=item *
Repository and branch must BOTH match together. This is to cover
the use case where a user has multiple branches checked out in
a single work directory. Commit files can be from different
branches.

A branch match is either:

=over 4

=item *
When no branches are listed in the fourth column. ("Match any.")

=item *
All elements from the fourth column are matched against each of 
the tag names for $ARGV[1..$#ARGV] found in the %branches file.

=back

=item *
'allow' match remove that match from the tally map.

=item *
Restricted ('deny') matches are saved in the %repository_matches 
table.

=item *
If there is a match on user, repository and branch:

  If repository, branch and user match
    if 'deny'
      add %repository_matches entries to %restricted_entries
    else if 'allow'
      remove %repository_matches entries from %restricted_entries

=item *
At the end of all the 'cvsacl' line checks, check to see if there
are any entries in the %restricted_entries.  If so, then deny the
commit.

=back

=head2 Pseudocode

     read CVS/Entries file and create branch{file}->{branch} hash table
   + for each 'allow' and 'deny' line in the 'cvsacl' file:
   |   user match?   
   |     - Yes: set $user_match       = 1;
   |   repository and branch match?
   |     - Yes: add to %repository_matches;
   |   did user, repository match?
   |     - Yes: if 'deny' then 
   |                add %repository_matches -> %restricted_entries
   |            if 'allow'   then 
   |                remove %repository_matches <- %restricted_entries
   + end for loop
     any saved restrictions?
       no:  exit, 
            set exit code allowing commits and exit
       yes: report restrictions, 
            set exit code prohibiting commits and exit

=head2 Sanity Check

  1) file allow trumps a dir deny
     deny||java/lib
     allow||java/lib/README
  2) dir allow can undo a file deny
     deny||java/lib/README
     allow||java/lib
  3) file deny trumps a dir allow
     allow||java/lib
     deny||java/lib/README
  4) dir deny trumps a file allow
     allow||java/lib/README
     deny||java/lib
  ... so last match always takes precedence

=cut

$debug = 0;                     # Set to 1 for debug messages

%repository_matches = ();       # hash of match file and pattern from 'cvsacl'
                                # repository_matches --> [branch, matching-pattern]
                                # (Used during module/branch matching loop)

%restricted_entries = ();       # hash table of restricted commit files (from @ARGV)
                                # restricted_entries --> branch
                                # (If user/module/branch all match on an 'deny'
                                #  line, then entries added to this map.)

%branch;                        # hash table of key: commit file; value: branch
                                # Built from ".../CVS/Entries" file of directory 
                                # currently being examined

# ---------------------------------------------------------------- get CVSROOT
$cvsroot = $ENV{'CVSROOT'};
die "Must set CVSROOT\n" if !$cvsroot;
if ($cvsroot =~ /:([\/\w]*)$/) { # Filter ":pserver:", ":local:"-type prefixes
    $cvsroot = $1; 
}

# ------------------------------------------------------------- set file paths
$entries = "CVS/Entries";                                # client-side file???
$cvsaclfile = $cvsroot . "/CVSROOT/cvsacl";
$restrictfile = $cvsroot . "/CVSROOT/restrict_msg";
$restrictlog = $cvsroot . "/CVSROOT/restrict_log";

# --------------------------------------------------------------- process args
$user_name = processArgs(\@ARGV);

print("$$ \@ARGV after processArgs is: @ARGV.\n") if $debug;
print("$$ ========== Begin $PROGRAM_NAME for \"$ARGV[0]\" repository. ========== \n") if $debug;

# --------------------------------------------------------------- filter @ARGV
eval "print STDERR \$die='Unknown parameter $1\n' if !defined \$$1; \$$1=\$';"
    while ($ARGV[0] =~ /^(\w+)=/ && shift(@ARGV));
exit 255 if $die;		         # process any variable=value switches

print("$$ \@ARGV after shift processing contains:",join("\, ",@ARGV),".\n") if $debug;

# ---------------------------------------------------------------- get cvsroot
($repository = shift) =~ s:^$cvsroot/::;
grep($_ = $repository . '/' . $_, @ARGV);

print("$$ \$cvsroot is: $cvsroot.\n") if $debug;
print "$$ Repos: $repository\n","$$ ==== ",join("\n$$ ==== ",@ARGV),"\n" if $debug;

$exit_val = 0;			         # presume good exit value for commit

# ----------------------------------------------------------------------------
# ---------------------------------- create hash table $branch{file -> branch}
# ----------------------------------------------------------------------------

# Here's a typical Entries file:
#
#   /checkoutlist/1.4/Wed Feb  4 23:51:23 2004//
#   /cvsacl/1.3/Tue Feb 24 23:05:43 2004//
#   ...
#   /verifymsg/1.1/Fri Mar 16 19:56:24 2001//
#   D/backup////
#   D/temp////

open(ENTRIES, $entries) || die("Cannot open $entries.\n");
print("$$ File / Branch\n") if $debug;
my $i = 0;
while(<ENTRIES>) {
    chop;
    next if /^\s*$/;                    # Skip blank lines
    $i = $i + 1;
    if (m|
          /                             # 1st slash
          ([\w.-]*)                     # file name -> $1
          /                             # 2nd slash
          .*                            # revision number
          /                             # 3rd slash
          .*                            # date and time
          /                             # 4th slash
          .*                            # keyword
          /                             # 5th slash
          T?                            # 'T' constant
          (\w*)                         # branch    -> #2
	      |x) {
	$branch{$repository . '/' . $1} = ($2) ? $2 : "HEAD"; 
	print "$$ CVS Entry $i: $1/$2\n" if $debug;
    }
}
close(ENTRIES);

# ----------------------------------------------------------------------------
# ------------------------------------- evaluate each active line from 'cvsacl'
# ----------------------------------------------------------------------------
open (CVSACL, $cvsaclfile) || exit(0);	# It is ok for cvsacl file not to exist
while (<CVSACL>) {
    chop;
    next if /^\s*\#/;                               # skip comments
    next if /^\s*$/;                                # skip blank lines
    # --------------------------------------------- parse current 'cvsacl' line
    print("$$ ==========\n$$ Processing \'cvsacl\' line: $_.\n") if $debug;
    ($cvsacl_flag, $cvsacl_userIds, $cvsacl_modules, $cvsacl_branches) = split(/[\s,]*\|[\s,]*/, $_);

    # ------------------------------ Validate 'allow' or 'deny' line prefix
    if ($cvsacl_flag !~ /^allow/ && $cvsacl_flag !~ /^deny/) {
	print ("Bad cvsacl line: $_\n") if $debug;
	$log_text = sprintf "Bad cvsacl line: %s", $_; 
	write_restrictlog_record($log_text);
        next;
    }

    # -------------------------------------------------- init loop match flags
    $user_match = 0;
    %repository_matches = ();

    # ------------------------------------------------------------------------
    # ---------------------------------------------------------- user matching
    # ------------------------------------------------------------------------
    # $user_name considered "in user list" if actually in list or is NULL
    $user_match = (!$cvsacl_userIds || grep ($_ eq $user_name, split(/[\s,]+/,$cvsacl_userIds)));
    print "$$ \$user_name: $user_name \$user_match match flag is: $user_match.\n" if $debug;
    if (!$user_match) {
	next;                            # no match, skip to next 'cvsacl' line
    }

    # ------------------------------------------------------------------------
    # ---------------------------------------------------- repository matching
    # ------------------------------------------------------------------------
    if (!$cvsacl_modules) {                  # blank module list = all modules
	if (!$cvsacl_branches) {            # blank branch list = all branches
	    print("$$ Adding all modules to \%repository_matches; null " . 
                  "\$cvsacl_modules and \$cvsacl_branches.\n") if $debug;
	    for $commit_object (@ARGV) {
		$repository_matches{$commit_object} = [$branch{$commit_object}, $cvsacl_modules];
                print("$$ \$repository_matches{$commit_object} = " .
                      "[$branch{$commit_object}, $cvsacl_modules].\n") if $debug;
	    }
	}
	else {                            # need to check for repository match
	    @branch_list = split (/[\s,]+/,$cvsacl_branches);
	    print("$$ Branches from \'cvsacl\' record: ", join(", ",@branch_list),".\n") if $debug;
	    for $commit_object (@ARGV) {
		if (grep($branch{$commit_object}, @branch_list)) {
		    $repository_matches{$commit_object} = [$branch{$commit_object}, $cvsacl_modules];
		    print("$$ \$repository_matches{$commit_object} = " .
                          "[$branch{$commit_object}, $cvsacl_modules].\n") if $debug;
		}
	    }
	}
    }
    else {
	# ----------------------------------- check every argument combination
	# parse 'cvsacl' modules to array
	my @module_list = split(/[\s,]+/,$cvsacl_modules);
        # ------------- Check all modules in list for either file or directory
	my $fileType = "";
	if (($fileType = checkFileness(@module_list)) eq "") {
	    next;                                        # skip bad file types
	}
	# ---------- Check each combination of 'cvsacl' modules vs. @ARGV files
	print("$$ Checking matches for \@module_list: ", join("\, ",@module_list), ".\n") if $debug;
	# loop thru all command-line commit objects
        for $commit_object (@ARGV) {              
	    # loop thru all modules on 'cvsacl' line
            for $cvsacl_module (@module_list) { 
	        print("$$ Is \'cvsacl\': $cvsacl_modules pattern in: \@ARGV " . 
                      "\$commit_object: $commit_object?\n") if $debug;
		# Do match of beginning of $commit_object
	        checkModuleMatch($fileType, $commit_object, $cvsacl_module);
	    } # end for commit objects
	} # end for cvsacl modules
    } # end if

    print("$$ Matches for: \%repository_matches: ", join("\, ", (keys %repository_matches)), ".\n") if $debug;

    # ------------------------------------------------------------------------
    # ----------------------------------------------------- setting exit value
    # ------------------------------------------------------------------------
    if ($user_match && %repository_matches) {
        print("$$ An \"$cvsacl_flag\" match on User(s): $cvsacl_userIds; Module(s):" .
              " $cvsacl_modules; Branch(es): $cvsacl_branches.\n") if $debug;
	if ($cvsacl_flag eq "deny") {
	    # Add all matches to the hash of restricted modules
	    foreach $commitFile (keys %repository_matches) {
		print("$$ Adding \%repository_matches entry: $commitFile.\n") if $debug;
		$restricted_entries{$commitFile} = $repository_matches{$commitFile}[0];
	    }
	}
	else {
	    # Remove all matches from the restricted modules hash
	    foreach $commitFile (keys %repository_matches) {
		print("$$ Removing \%repository_matches entry: $commitFile.\n") if $debug;
		delete $restricted_entries{$commitFile};
	    }
	}
    }
    print "$$ ==== End of processing for \'cvsacl\' line: $_.\n" if $debug;
}
close(CVSACL);

# ----------------------------------------------------------------------------
# --------------------------------------- determine final 'commit' disposition
# ---------------------------------------------------------------------------- 
if (%restricted_entries) {                           # any restricted entries?
    $exit_val = 1;                                              # don't commit
    print("**** Access denied: Insufficient authority for user: '$user_name\' " .
          "to commit to \'$repository\'.\n**** Contact CVS Administrators if " .
          "you require update access to these directories or files.\n");
    print("**** file(s)/dir(s) restricted were:\n\t", join("\n\t",keys %restricted_entries), "\n");
    printOptionalRestrictionMessage();
    write_restrictlog();
}
elsif (!$exit_val && $debug) {
    print "**** Access allowed: Sufficient authority for commit.\n";
}

print "$$ ==== \$exit_val = $exit_val\n" if $debug;
exit($exit_val);

# ----------------------------------------------------------------------------
# -------------------------------------------------------------- end of "main"
# ----------------------------------------------------------------------------


# ----------------------------------------------------------------------------
# -------------------------------------------------------- process script args
# ----------------------------------------------------------------------------
sub processArgs {

# This subroutine is passed a reference to @ARGV. 

# If @ARGV contains a "-u" entry, use that as the effective userId.  In this 
# case, the userId is the client-side userId that has been passed to this 
# script by the commit_prep script.  (This is why the commit_prep script must 
# be placed *before* the cvs_acls script in the commitinfo admin file.)

# Otherwise, pull the userId from the server-side environment.

    my $userId = "";
    my ($argv) = shift;             # pick up ref to @ARGV
    my @argvClone = ();             # immutable copy for foreach loop
    for ($i=0; $i<(scalar @{$argv}); $i++) {
	$argvClone[$i]=$argv->[$i]; 
    }

    print("$$ \@_ to processArgs is: @_.\n") if $debug;

    # Parse command line arguments (file list is seen as one arg)
    foreach $arg (@argvClone) {
	print("$$ \$arg for processArgs loop is: $arg.\n") if $debug;
	# Set $debug flag?
	if ($arg eq '-d') {
	    shift @ARGV;
	    $debug = 1;
	    print("$$ \$debug flag set on.\n") if $debug;
	    print STDERR "Debug turned on...\n";
	}
	# Passing in a client-side userId?
	elsif ($arg eq '-u') {
	    shift @ARGV;
	    $userId = shift @ARGV;
	    print("$$ client-side \$userId set to: $userId.\n") if $debug;
	} 
	# An override for the default restrictlog file?
	elsif ($arg eq '-f') {
	    shift @ARGV;
	    $restrictlog = shift @ARGV;
	} 
	else {
	    next;
	}
    }

    # No client-side userId passed? then get from server env
    if (!$userId) {
        $userId = $ENV{"USER"} if !($userId = $ENV{"LOGNAME"});
	    print("$$ server-side \$userId set to: $userId.\n") if $debug;
    }

    print("$$ processArgs returning \$userId: $userId.\n") if $debug;
    return $userId;

}


# ----------------------------------------------------------------------------
# --------------------- Check all modules in list for either file or directory
# ----------------------------------------------------------------------------
sub checkFileness {

# Module patterns on the 'cvsacl' record can be files or directories. 
# If it's a directory, we pattern-match the directory name from 'cvsacl' 
# against the left side of the committed filename to see if the file is in 
# that hierarchy.  By contrast, files use an explicit match.  If the entries
# are neither files nor directories, then the cvsacl file has been set up
# incorrectly; we return a "" and the caller skips that line as invalid.
#
# This function determines whether the entries on the 'cvsacl' record are all
# directories or all files; it cannot be a mixture.  This restriction put in
# to simplify the logic (without taking away much functionality).

    my @module_list = @_;
    print("$$ Checking \"fileness\" or \"dir-ness\" for \@module_list entries.\n") if $debug;
    print("$$     Entries are: ", join("\, ",@module_list), ".\n") if $debug;
    my $filetype = "";
    for $cvsacl_module (@module_list) {
        my $reposDirName = $cvsroot . '/' . $cvsacl_module;
        my $reposFileName = $reposDirName . "\,v";
        print("$$ In checkFileness: \$reposDirName: $reposDirName; \$reposFileName: $reposFileName.\n") if $debug;
        if (((-d $reposDirName) && ($filetype eq "file")) || ((-f $reposFileName) && ($filetype eq "dir"))) {
            print("Can\'t mix files and directories on single \'cvsacl\' file record; skipping entry.\n");
	    print("    Please contact a CVS administrator.\n");
	    $filetype = "";
	    last;
        }
        elsif (-d $reposDirName) { 
            $filetype = "dir";
	    print("$$ $reposDirName is a directory.\n") if $debug;
	}
        elsif (-f $reposFileName) {
            $filetype = "file";
	    print("$$ $reposFileName is a regular file.\n") if $debug;
	}
        else {
            print("***** Item to commit was neither a regular file nor a directory.\n");
	    print("***** Current \'cvsacl\' line ignored.\n");
	    print("***** Possible problem with \'cvsacl\' admin file. Please contact a CVS administrator.\n");
	    $filetype = "";
	    $text = sprintf("Module entry on cvsacl line: %s is not a valid file or directory.\n", $cvsacl_module);
	    write_restrictlog_record($text);
	    last;
        } # end if
    } # end for

    print("$$ checkFileness will return \$filetype: $filetype.\n") if $debug;
    return $filetype;
}


# ----------------------------------------------------------------------------
# ----------------------------------------------------- check for module match
# ----------------------------------------------------------------------------
sub checkModuleMatch {

# This subroutine checks for a match between the directory or file pattern 
# specified in the 'cvsacl' file (i.e., $cvsacl_modules) versus the commit file 
# objects passed into the script via @ARGV (i.e., $commit_object). 

# The directory pattern only has to match the beginning portion of the commit 
# file's name for a match since all files under that directory are considered 
# a match. File patterns must exactly match.

# Since (theoretically, if not normally in practice) a working directory can
# contain a mixture of files from different branches, this routine checks to 
# see if there is also a match on branch before considering the file 
# comparison a match.

    my $match_flag = "";

    print("$$ \@_ in checkModuleMatch is: @_.\n") if $debug;
    my ($type,$commit_object,$cvsacl_module) = @_;

    if ($type eq "file") {             # Do exact file match of $commit_object
	if ($commit_object eq $cvsacl_module) {
	    $match_flag = "file";
	}                        # Do dir match at beginning of $commit_object
    }
    elsif ($commit_object =~ /^$cvsacl_module\//) {
        $match_flag = "dir";
    }

    if ($match_flag) {
	print("$$ \$repository: $repository matches \$commit_object: $commit_object.\n") if $debug;
	if (!$cvsacl_branches) {             # empty branch pattern matches all
	    print("$$ blank \'cvsacl\' branch matches all commit files.\n") if $debug;
	    $repository_matches{$commit_object} = [$branch{$commit_object}, $cvsacl_module];
	    print("$$ \$repository_matches{$commit_object} = [$branch{$commit_object}, $cvsacl_module].\n") if $debug;
	}
	else {                             # otherwise check branch hash table
	    @branch_list = split (/[\s,]+/,$cvsacl_branches);
	    print("$$ Branches from \'cvsacl\' record: ", join(", ",@branch_list),".\n") if $debug;
	    if (grep(/$branch{$commit_object}/, @branch_list)) {
		$repository_matches{$commit_object} = [$branch{$commit_object}, $cvsacl_module];
		print("$$ \$repository_matches{$commit_object} = [$branch{$commit_object}, " .
                      "$cvsacl_module].\n") if $debug;
	    }
	}
    }

}

# ----------------------------------------------------------------------------
# ------------------------------------------------------- check for file match
# ----------------------------------------------------------------------------
sub printOptionalRestrictionMessage {

# This subroutine optionally prints site-specific file restriction information
# whenever a restriction condition is met.  If the file 'restrict_msg' does 
# not exist, the routine immediately exits.  If there is a 'restrict_msg' file
# then all the contents are printed at the end of the standard restriction 
# message.

# As seen from examining the definition of $restrictfile, the default filename
# is: $CVSROOT/CVSROOT/restrict_msg.

    open (RESTRICT, $restrictfile) || return;	# It is ok for cvsacl file not to exist
    while (<RESTRICT>) {
	chop;
	# print out each line
	print("**** $_\n");
    }

}

# ----------------------------------------------------------------------------
# ---------------------------------------------------------- write log message
# ----------------------------------------------------------------------------
sub write_restrictlog {

# This subroutine iterates through the list of restricted entries and logs 
# each one to the error logfile.

    # write each line in @text out separately
    foreach $commitfile (keys %restricted_entries) {
	$log_text = sprintf "Commit attempt by: %s for: %s on branch: %s", 
	                    $user_name, $commitfile, $branch{$commitfile};
	write_restrictlog_record($log_text);
    }

}

# ----------------------------------------------------------------------------
# ---------------------------------------------------------- write log message
# ----------------------------------------------------------------------------
sub write_restrictlog_record {

# This subroutine receives a scalar string and writes it out to the 
# $restrictlog file as a separate line. Each line is prepended with the date 
# and time in the format: "2004/01/30 12:00:00 ".

    $text = shift;

    # return quietly if there is a problem opening the log file.
    open(FILE, ">>$restrictlog") || return;

    (@time) = localtime();

    # write each line in @text out separately
    $log_record = sprintf "%04d/%02d/%02d %02d:%02d:%02d %s.\n", 
                      $time[5]+1900, $time[4]+1, $time[3], $time[2], $time[1], $time[0], $text;
    print FILE $log_record;
    print("$$ restrict_log record being written: $log_record to $restrictlog.\n") if $debug;
    
    close(FILE);
}
