#!/usr/bin/perl -w

# ====================================================================
# commit-access-control.pl: check if the user that submitted the
# transaction TXN-NAME has the appropriate rights to perform the
# commit in repository REPOS using the permissions listed in the
# configuration file CONF_FILE.
#
# Usage: commit-access-control.pl REPOS TXN-NAME CONF_FILE
#    
# ====================================================================
# Copyright (c) 2000-2003 CollabNet.  All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution.  The terms
# are also available at http://subversion.tigris.org/license-1.html.
# If newer versions of this license are posted there, you may use a
# newer version instead, at your option.
#
# This software consists of voluntary contributions made by many
# individuals.  For exact contribution history, see the revision
# history and logs, available at http://subversion.tigris.org/.
# ====================================================================

use strict;
use Carp;
use Config::IniFiles 2.27;

######################################################################
# Configuration section.

# Svnlook path.
my $svnlook = "@SVN_BINDIR@/svnlook";

# Since the path to svnlook depends upon the local installation
# preferences, check that the required program exists to insure that
# the administrator has set up the script properly.
{
  my $ok = 1;
  foreach my $program ($svnlook)
    {
      if (-e $program)
        {
          unless (-x $program)
            {
              warn "$0: required program `$program' is not executable, ",
                   "edit $0.\n";
              $ok = 0;
            }
        }
      else
        {
          warn "$0: required program `$program' does not exist, edit $0.\n";
          $ok = 0;
        }
    }
  exit 1 unless $ok;
}

######################################################################
# Initial setup/command-line handling.

&usage unless @ARGV == 3;

my $repos        = shift;
my $txn          = shift;
my $cfg_filename = shift;

unless (-e $repos)
  {
    &usage("$0: repository directory `$repos' does not exist.");
  }
unless (-d $repos)
  {
    &usage("$0: repository directory `$repos' is not a directory.");
  }
unless (-e $cfg_filename)
  {
    &usage("$0: configuration file `$cfg_filename' does not exist.");
  }
unless (-r $cfg_filename)
  {
    &usage("$0: configuration file `$cfg_filename' is not readable.");
  }

# Define two constant subroutines to stand for read-only or read-write
# access to the repository.
sub ACCESS_READ_ONLY  () { 'read-only' }
sub ACCESS_READ_WRITE () { 'read-write' }

######################################################################
# Load the configuration file and validate it.
my $cfg = Config::IniFiles->new(-file => $cfg_filename);
unless ($cfg)
  {
    die "$0: error in loading configuration file `$cfg_filename'",
         @Config::IniFiles::errors ? ":\n@Config::IniFiles::errors\n"
                                   : ".\n";
  }

# Go through each section of the configuration file, validate that
# each section has the required parameters and complain about unknown
# parameters.  Compile any regular expressions.
my @sections = $cfg->Sections;
{
  my $ok = 1;
  foreach my $section (@sections)
    {
      # First check for any unknown parameters.
      foreach my $param ($cfg->Parameters($section))
        {
          next if $param eq 'match';
          next if $param eq 'users';
          next if $param eq 'access';
          warn "$0: config file `$cfg_filename' section `$section' parameter ",
               "`$param' is being ignored.\n";
          $cfg->delval($section, $param);
        }

      my $access = $cfg->val($section, 'access');
      if (defined $access)
        {
          unless ($access eq ACCESS_READ_ONLY or $access eq ACCESS_READ_WRITE)
            {
              warn "$0: config file `$cfg_filename' section `$section' sets ",
                "`access' to illegal value `$access'.\n";
              $ok = 0;
            }
        }
      else
        {
          warn "$0: config file `$cfg_filename' section `$section' does ",
            "not set `access' parameter.\n";
          $ok = 0;
        }

      my $match_regex = $cfg->val($section, 'match');
      if (defined $match_regex)
        {
          # To help users that automatically write regular expressions
          # that match the beginning of absolute paths using ^/,
          # remove the / character because subversion paths, while
          # they start at the root level, do not begin with a /.
          $match_regex =~ s#^\^/#^#;

          my $match_re;
          eval { $match_re = qr/$match_regex/ };
          if ($@)
            {
              warn "$0: config file `$cfg_filename' section `$section' ",
                   "`match' regex `$match_regex' does not compile:\n$@\n";
              $ok = 0;
            }
          else
            {
              $cfg->newval($section, 'match_re', $match_re);
            }
        }
      else
        {
          warn "$0: config file `$cfg_filename' section `$section' does ",
               "not set `match' parameter.\n";
          $ok = 0;
        }
    }
  exit 1 unless $ok;
}

######################################################################
# Harvest data using svnlook.

# Change into /tmp so that svnlook diff can create its .svnlook
# directory.
my $tmp_dir = '/tmp';
chdir($tmp_dir)
  or die "$0: cannot chdir `$tmp_dir': $!\n";

# Get the author from svnlook.
my @svnlooklines = &read_from_process($svnlook, 'author', $repos, '-t', $txn);
my $author = shift @svnlooklines;
unless (length $author)
  {
    die "$0: txn `$txn' has no author.\n";
  }

# Figure out what directories have changed using svnlook..
my @dirs_changed = &read_from_process($svnlook, 'dirs-changed', $repos,
                                      '-t', $txn);

# Lose the trailing slash in the directory names if one exists, except
# in the case of '/'.
my $rootchanged = 0;
for (my $i=0; $i<@dirs_changed; ++$i)
  {
    if ($dirs_changed[$i] eq '/')
      {
        $rootchanged = 1;
      }
    else
      {
        $dirs_changed[$i] =~ s#^(.+)[/\\]$#$1#;
      }
  }

# Figure out what files have changed using svnlook.
my @files_changed;
foreach my $line (&read_from_process($svnlook, 'changed', $repos, '-t', $txn))
  {
    # Split the line up into the modification code and path, ignoring
    # property modifications.
    if ($line =~ /^..  (.*)$/)
      {
        push(@files_changed, $1);
      }
  }

# Create the list of all modified paths.
my @changed = (@dirs_changed, @files_changed);

# There should always be at least one changed path.  If there are
# none, then there maybe something fishy going on, so just exit now
# indicating that the commit should not proceed.
unless (@changed)
  {
    die "$0: no changed paths found in txn `$txn'.\n";
  }

######################################################################
# Populate the permissions table.

# Set a hash keeping track of the access rights to each path.  Because
# this is an access control script, set the default permissions to
# read-only.
my %permissions;
foreach my $path (@changed)
  {
    $permissions{$path} = ACCESS_READ_ONLY;
  }

foreach my $section (@sections)
  {
    # Decide if this section should be used.  It should be used if
    # there are no users listed at all for this section, or if there
    # are users listed and the author is one of them.
    my $use_this_section;

    # If there are any users listed, then check if the author of this
    # commit is listed in the list.  If not, then delete the section,
    # because it won't apply.
    #
    # The configuration file can list users like this on multiple
    # lines:
    #   users = joe@mysite.com betty@mysite.com
    #   users = bob@yoursite.com

    # Because of the way Config::IniFiles works, check if there are
    # any users at all with the scalar return from val() and if there,
    # then get the array value to get all users.
    my $users = $cfg->val($section, 'users');
    if (defined $users and length $users)
      {
        my $match_user = 0;
        foreach my $entry ($cfg->val($section, 'users'))
          {
            unless ($match_user)
              {
                foreach my $user (split(' ', $entry))
                  {
                    if ($author eq $user)
                      {
                        $match_user = 1;
                        last;
                      }
                  }
              }
          }

        $use_this_section = $match_user;
      }
    else
      {
        $use_this_section = 1;
      }

    next unless $use_this_section;

    # Go through each modified path and match it to the regular
    # expression and set the access right if the regular expression
    # matches.
    my $access   = $cfg->val($section, 'access');
    my $match_re = $cfg->val($section, 'match_re');
    foreach my $path (@changed)
      {
        $permissions{$path} = $access if $path =~ $match_re;
      }
  }

# Go through all the modified paths and see if any permissions are
# read-only.  If so, then fail the commit.
my @failed_paths;
foreach my $path (@changed)
  {
    if ($permissions{$path} ne ACCESS_READ_WRITE)
      {
        push(@failed_paths, $path);
      }
  }

if (@failed_paths)
  {
    warn "$0: user `$author' does not have permission to commit to ",
         @failed_paths > 1 ? "these paths:\n  " : "this path:\n  ",
         join("\n  ", @failed_paths), "\n"; 
    exit 1;
  }
else
  {
    exit 0;
  }

sub usage
{
  warn "@_\n" if @_;
  die "usage: $0 REPOS TXN-NAME CONF_FILE\n";
}

sub safe_read_from_pipe
{
  unless (@_)
    {
      croak "$0: safe_read_from_pipe passed no arguments.\n";
    }
  print "Running @_\n";
  my $pid = open(SAFE_READ, '-|');
  unless (defined $pid)
    {
      die "$0: cannot fork: $!\n";
    }
  unless ($pid)
    {
      open(STDERR, ">&STDOUT")
        or die "$0: cannot dup STDOUT: $!\n";
      exec(@_)
        or die "$0: cannot exec `@_': $!\n";
    }
  my @output;
  while (<SAFE_READ>)
    {
      chomp;
      push(@output, $_);
    }
  close(SAFE_READ);
  my $result = $?;
  my $exit   = $result >> 8;
  my $signal = $result & 127;
  my $cd     = $result & 128 ? "with core dump" : "";
  if ($signal or $cd)
    {
      warn "$0: pipe from `@_' failed $cd: exit=$exit signal=$signal\n";
    }
  if (wantarray)
    {
      return ($result, @output);
    }
  else
    {
      return $result;
    }
}

sub read_from_process
  {
  unless (@_)
    {
      croak "$0: read_from_process passed no arguments.\n";
    }
  my ($status, @output) = &safe_read_from_pipe(@_);
  if ($status)
    {
      if (@output)
        {
          die "$0: `@_' failed with this output:\n", join("\n", @output), "\n";
        }
      else
        {
          die "$0: `@_' failed with no output.\n";
        }
    }
  else
    {
      return @output;
    }
}
