#!/usr/bin/perl use strict; use Config::Simple; use DBI; use File::Basename; use SVN::Core; use SVN::Repos; use SVN::Fs; my $CFG_FILE = '@CFGDIR@/codepot.ini'; my $REPOFS = $ARGV[0]; my $REPOBASE = basename($REPOFS); my $TRANSACTION = $ARGV[1]; my $QC = ''; my %SVN_ACTIONS = ( 'A ' => 'add', 'U ' => 'update', 'D ' => 'delete', '_U' => 'propset', 'UU' => 'update/propset' ); my %SVN_ACTION_VERBS = ( $SVN::Fs::PathChange::modify => 'modify', $SVN::Fs::PathChange::add => 'add', $SVN::Fs::PathChange::delete => 'delete', $SVN::Fs::PathChange::replace => 'replace' ); sub get_config { my $cfg = new Config::Simple(); if (!$cfg->read ($CFG_FILE)) { return undef; } my $config = { database_hostname => $cfg->param ('database_hostname'), database_port => $cfg->param ("database_port"), database_username => $cfg->param ('database_username'), database_password => $cfg->param ('database_password'), database_name => $cfg->param ('database_name'), database_driver => $cfg->param ('database_driver'), database_prefix => $cfg->param ('database_prefix'), svn_min_commit_message_length => $cfg->param ('svn_min_commit_message_length'), svn_restricted_topdirs => $cfg->param('svn_restricted_topdirs'), svn_restriction_allowed_subdir_depth_min => $cfg->param('svn_restriction_allowed_subdir_depth_min'), svn_restriction_allowed_subdir_depth_max => $cfg->param('svn_restriction_allowed_subdir_depth_max') }; return $config; } sub open_database { my ($cfg) = @_; my $dbtype = $cfg->{database_driver}; my $dbname = $cfg->{database_name}; my $dbhost = $cfg->{database_hostname}; my $dbport = $cfg->{database_port}; if ($dbtype eq 'postgre') { $dbtype = 'Pg'; } elsif ($dbtype eq 'oci8') { $dbtype = 'Oracle'; } elsif ($dbtype eq 'mysqli') { $dbtype = 'mysql'; } elsif ($dbtype eq 'sqlite') { $dbtype = 'SQLite'; } my $dbstr; my $dbuser; my $dbpass; if ($dbtype eq 'Oracle') { $QC = '"'; $dbstr = "DBI:$dbtype:"; $dbuser = $cfg->{database_username} . '/' . $cfg->{database_password} . '@' . $dbhost; $dbpass = ''; } elsif ($dbtype eq 'SQLite') { $dbstr = "DBI:$dbtype:database=$dbhost;"; $dbuser = $cfg->{database_username}; $dbpass = $cfg->{database_password}; } else { $dbstr = "DBI:$dbtype:database=$dbname;"; if (length($dbhost) > 0) { $dbstr .= "host=$dbhost;"; } if (length($dbport) > 0) { $dbstr .= "port=$dbport;"; } $dbuser = $cfg->{database_username}; $dbpass = $cfg->{database_password}; } my $dbh = DBI->connect( $dbstr, $dbuser, $dbpass, { RaiseError => 0, PrintError => 0, AutoCommit => 0 } ); return $dbh; } sub close_database { my ($dbh) = @_; $dbh->disconnect (); } sub is_project_member { my ($dbh, $prefix, $projectid, $userid) = @_; my $query = $dbh->prepare ("SELECT ${QC}projectid${QC} FROM ${QC}${prefix}project_membership${QC} WHERE ${QC}userid${QC}=? AND ${QC}projectid${QC}=?"); if (!$query || !$query->execute ($userid, $projectid)) { return (-1, $dbh->errstr()); } my @row = $query->fetchrow_array; $query->finish (); return (((scalar(@row) > 0)? 1: 0), undef); } sub is_project_commitable { my ($dbh, $prefix, $projectid) = @_; my $query = $dbh->prepare ("SELECT ${QC}commitable${QC} FROM ${QC}${prefix}project${QC} WHERE ${QC}id${QC}=?"); if (!$query || !$query->execute ($projectid)) { return (-1, $dbh->errstr()); } my @row = $query->fetchrow_array; $query->finish (); return (((scalar(@row) > 0 && $row[0] eq 'Y')? 1: 0), undef); } sub contains_repeated_chars { my ($str, $limit) = @_; my $len = length($str); my $lc = ''; my $count = 1; for (my $i = 0; $i < $len; $i++) { my $c = substr($str, $i, 1); if ($lc eq $c) { $count++; if ($count > $limit) { return 1; } } else { $count = 1; $lc = $c; } } return 0; } sub check_commit_message { my ($minlen) = @_; my $pool = SVN::Pool->new(undef); #my $config = SVN::Core::config_get_config(undef); #my $fs = eval { SVN::Fs::open ($REPOFS, $config, $pool) }; my $svn = eval { SVN::Repos::open ($REPOFS, $pool) }; if (!defined($svn)) { print (STDERR "Cannot open svn - $REPOFS\n"); return -1; # error } my $fs = $svn->fs (); if (!defined($fs)) { print (STDERR "Cannot open fs - $REPOFS\n"); return -1; # error } my $txn = eval { $fs->open_txn ($TRANSACTION) }; if (!defined($txn)) { print (STDERR "Cannot open transaction - $TRANSACTION\n"); return -1; } my $log = $txn->prop ('svn:log'); # TODO: block a certain message patterns. create a configuration item # for this #if ($log =~ /[[:punct:]]{5,}/ || $log =~ /[[:alpha:]]{40,}/ || contains_repeated_chars($log, 4)) #{ # print (STDERR "Commit message rejected\n"); # return 0; #} $log =~ s/\s{2,}/ /g; $log =~ s/([[:punct:]]{1,2}\s+){3,}/ /g; $log =~ s/[[:punct:]]{3,}/ /g; $log =~ s/^\s+|\s+$//g; # trim leading spaces and trailing spaces if (length($log) < $minlen) { print (STDERR "Commit message too short. meaningful part must be >= $minlen\n"); return 0; } if ($log =~ /^[[:punct:][:space:]]+$/) { print (STDERR "Commit message meaningless\n"); return 0; } return 1; } sub restrict_changes_in_directory_old { my ($dir, $min_level, $max_level) = @_; my @change_info = `svnlook changed --copy-info -t "${TRANSACTION}" "${REPOFS}"`; # 'A ' Item added to repository # 'D ' Item deleted from repository # 'U ' File contents changed # '_U' Properties of item changed; note the leading underscore # 'UU' File contents and properties changed # ------------------------------------------------------------ # + on the third column to indicate copy # fourth column is empty. # ------------------------------------------------------------ # When copy-info is used, the source of the copy is shown # on the next line aligned at the file name part and # begins with spaces. # # A + y/t/ # (from c/:r2) # ------------------------------------------------------------ # # Renaming a file in the copied directory looks like this. # D tags/xxx-1.2.3/2/screenrc # A + tags/xxx-1.2.3/2/screenrc.x # (from tags/xxx-1.2.3/2/screenrc:r10) # # If the deletion of the file is disallowed, the whole # transaction is blocked. so I don't need to care about # copied addition. # ------------------------------------------------------------ foreach my $line(@change_info) { chomp ($line); print (STDERR "... CHANGE INFO => $line\n"); } my $disallowed = 0; while (@change_info) #foreach my $line(@change_info) { my $line = shift (@change_info); chomp ($line); if ($line =~ /^(A |U |D |_U|UU) ${dir}\/(.*)$/) { my $action = "${1}"; my $affected_file = "${dir}/${2}"; my $affected_file_nodir = "${2}"; my $action_verb = $SVN_ACTIONS{$action}; if (rindex($affected_file, '/') + 1 == length($affected_file)) { # the last character is a slash. so it's a directory. # let's allow most of the operations on a directory. #if ($action eq 'D ') #{ my @segs = split ('/', $affected_file_nodir); my $num_segs = scalar(@segs); # NOTE: for a string like abc/def/, split() seems to return 2 segments only. if ($affected_file_nodir eq '') { # it is the main directory itself. # allow operation on it. } elsif ($num_segs < $min_level || $num_segs > $max_level) { # disallow deletion if the directory name to be deleted # matches a tag pattern print (STDERR "Disallowed to ${action_verb} a directory - ${affected_file}\n"); $disallowed++; } #} } else { print (STDERR "Disallowed to ${action_verb} a file - ${affected_file}\n"); $disallowed++; } } elsif ($line =~ /^(A )\+ ${dir}\/(.*)$/) { my $action = "${1}"; my $affected_file = "${dir}/${2}"; # copying # # A + tags/xxx-1.2.3/2/smi.conf.2 # (from tags/xxx-1.2.3/2/smi.conf:r10) # my $source_line = shift (@change_info); chomp ($source_line); if ($source_line =~ / ^ # beginning of string \W* # 0 or more white-spaces \( # opening parenthesis \S+ # 1 or more non-space characters \W+ # 1 or more space characters (.+) # 1 or more characters :r[0-9]+ # :rXXX where XXX is digits \) # closing parenthesis $ # end of string /x) { my $source_file = "${1}"; if (rindex($affected_file, '/') + 1 != length($affected_file)) { # the file beging added by copyiung is not a directory. # it disallows individual file copying. # copy a whole directory at one go. print (STDERR "Disallowed to copy $source_file to $affected_file\n"); $disallowed++; } elsif ($source_file =~ /^${dir}\/(.*)$/) { # i don't want to be a copied file or directory to be # a source of another copy operation. print (STDERR "Disallowed to copy $source_file to $affected_file\n"); $disallowed++; } else { # Assume xxx is a directory. # Assume min_level is 1 and max_level is 2. # # If the following two commans are executed, # svn copy trunk/xxx tags/my-4.0.0 # svn copy trunk/xxx tags/my-4.0.0/1 # # svnlook returns the following text. # A + tags/my-4.0.0/ # (from trunk/xxx/:r16) # A + tags/my-4.0.0/1/ # (from trunk/xxx/:r16) # # if the script knows that tags/my-4.0.0 is created via copying, # i want this script to prevent copying other sources into it. # this case is not fully handled by this script. # TODO: DISALLOW THIS if the parent directory is a copied directory my $pardir = dirname ($affected_file); } } } #else #{ # print (STDERR "OK ... ${line}\n"); #} } return ($disallowed > 0)? -1: 0; } sub restrict_changes_in_topdirs { my ($min_level, $max_level, @topdirs) = @_; my $disallowed = 0; my $pool = SVN::Pool->new(undef); #my $config = SVN::Core::config_get_config(undef); #my $fs = eval { SVN::Fs::open ($REPOFS, $config, $pool) }; my $svn = eval { SVN::Repos::open ($REPOFS, $pool) }; if (!defined($svn)) { print (STDERR "Cannot open svn - $REPOFS\n"); return -1; # error } my $fs = $svn->fs (); if (!defined($fs)) { print (STDERR "Cannot open fs - $REPOFS\n"); return -1; # error } my $txn = eval { $fs->open_txn ($TRANSACTION) }; if (!defined($txn)) { print (STDERR "Cannot open transaction - $TRANSACTION\n"); return -1; } my $root = eval { $txn->root() }; if (!defined($root)) { print (STDERR "Cannot open root of transaction - $TRANSACTION\n"); return -1; } my $paths_changed = eval { $root->paths_changed() }; if (!defined($paths_changed)) { # no change information found. return ok $root->close_root (); return 0; } foreach my $affected_file(keys(%$paths_changed)) { my $chg = $paths_changed->{$affected_file}; my $action = $chg->change_kind(); my $action_verb = $SVN_ACTION_VERBS{$action}; if (length($action_verb) <= 0) { $action_verb = "work on"; } my $is_source_file_dir = 0; my $is_affected_file_dir = eval { $root->is_dir($affected_file) }; #$chg->text_mod(), $chg->prop_mod() #my $affected_rev_id = eval { SVN::Fs::unparse_id($chg->node_rev_id()) }; my $source_file = undef; #my $source_id = undef; if ($action == $SVN::Fs::PathChange::add) { $source_file = eval { $root->copied_from($affected_file) }; #if ($source_file) #{ # $source_id = eval { SVN::Fs::unparse_id($root->node_id($source_file)) }; #} } elsif ($action == $SVN::Fs::PathChange::delete) { # when a file is deleted, $root->is_dir() doesn't seem to # return the right type. use the revision root to determine it. my $rev_root = $fs->revision_root($fs->youngest_rev()); $is_affected_file_dir = eval { $rev_root->is_dir ($affected_file) }; $rev_root->close_root(); } #print STDERR "@@@@@ [$affected_file] [$action_verb] [$source_file] [$is_source_file_dir] [$is_affected_file_dir]\n"; foreach my $topdir(@topdirs) { if ($affected_file =~ /^\/${topdir}\/(.*)$/) { # the affected file is located under the given directory. my $affected_file_nodir = "${1}"; if (defined($source_file)) { # it's being copied. if (!$is_affected_file_dir) { # the file beging added by copying is not a directory. # it disallows individual file copying. # copy a whole directory at one go. print (STDERR "Disallowed to copy ${source_file} to ${affected_file}\n"); $disallowed++; } elsif ($source_file =~ /^\/${topdir}\/(.*)$/) { # i don't want to be a copied file or directory to be # a source of another copy operation. print (STDERR "Disallowed to copy ${source_file} to ${affected_file}\n"); $disallowed++; } else { # TODO: DISALLOW THIS if the parent directory is a copied directory #my $pardir = dirname ($affected_file); } } else { if ($is_affected_file_dir) { my @segs = split ('/', $affected_file_nodir); my $num_segs = scalar(@segs); # NOTE: for a string like abc/def/, split() seems to return 2 segments only. if ($affected_file_nodir eq '') { # it is the main directory itself. # allow operation on it. } elsif ($num_segs < $min_level || $num_segs > $max_level) { # disallow deletion if the directory name to be deleted # matches a tag pattern print (STDERR "Disallowed to ${action_verb} a directory - ${affected_file}\n"); $disallowed++; } } else { print (STDERR "Disallowed to ${action_verb} a file - ${affected_file}\n"); $disallowed++; } } } } } # 'svn rename' within the restricted directory is disallowed because # it splits to deletion and addition. for this reason, you're supposed # to copy from the trunk or branch source again. # # $ svn rename tags/my-1.0.0 tags/my-2.0.0 # $ svn commit -m "XXXXXXXXXXXX" # Deleting tags/my-1.0.0 # Adding tags/my-2.0.0 # $root->close_root (); return ($disallowed > 0)? -1: 0; } sub restrict_read_only_files() { my $disallowed = 0; my $pool = SVN::Pool->new(undef); #my $config = SVN::Core::config_get_config(undef); #my $fs = eval { SVN::Fs::open($REPOFS, $config, $pool) }; my $svn = eval { SVN::Repos::open($REPOFS, $pool) }; if (!defined($svn)) { print (STDERR "Cannot open svn - $REPOFS\n"); return -1; # error } my $fs = $svn->fs(); if (!defined($fs)) { print (STDERR "Cannot open fs - $REPOFS\n"); return -1; # error } my $txn = eval { $fs->open_txn ($TRANSACTION) }; if (!defined($txn)) { print (STDERR "Cannot open transaction - $TRANSACTION\n"); return -1; } my $root = eval { $txn->root() }; if (!defined($root)) { print (STDERR "Cannot open root of transaction - $TRANSACTION\n"); return -1; } my $paths_changed = eval { $root->paths_changed() }; if (!defined($paths_changed)) { # no change information found. return ok $root->close_root (); return 0; } my $fs_root = $fs->revision_root($fs->youngest_rev()); foreach my $affected_file(keys(%$paths_changed)) { my $chg = $paths_changed->{$affected_file}; my $action = $chg->change_kind(); my $action_verb = $SVN_ACTION_VERBS{$action}; if (length($action_verb) <= 0) { $action_verb = "work on"; } ## check codepot:readonly only if there is content change. ## property-only change is always allowed. ## directory addition is probably allowed. ## TODO: prevent this? next if ($action != $SVN::Fs::PathChange::delete && !$chg->text_mod()); my $file = $affected_file; my $readonly = eval { $fs_root->node_prop($file, 'codepot:readonly') }; if ($readonly eq 'yes') { $disallowed = 1; print (STDERR "Unable to $action_verb $file - read-only\n"); } elsif ($readonly eq 'no') { ## no is set explicitly on the node itself. ## don't proceed to check the parent side. ## change is granted immediately. ## DO NOTHING HERE } else { ## check permission in the parent side while ((my $slash = rindex($file, "/")) >= 0) { $file = substr($file, 0, $slash); my $tmp = $file; $tmp = '/' if ($tmp eq ''); $readonly = eval { $fs_root->node_prop($tmp, 'codepot:readonly') }; if ($readonly eq 'yes') { $disallowed = 1; print (STDERR "Unable to $action_verb $affected_file - $tmp set to read-only\n"); } elsif ($readonly eq 'no') { last; } } } } $root->close_root (); return ($disallowed > 0)? -1: 0; } #------------------------------------------------------------ # MAIN #------------------------------------------------------------ my $cfg = get_config (); if (!defined($cfg)) { print (STDERR "Cannot load codepot configuration file\n"); exit (1); } if (check_commit_message ($cfg->{svn_min_commit_message_length}) <= 0) { exit (1); } # TODO: enable per-project settings for topdir restriction my $min_level = $cfg->{svn_restriction_allowed_subdir_depth_min}; if (!defined($min_level)) { $min_level = 0; } my $max_level = $cfg->{svn_restriction_allowed_subdir_depth_max}; if (!defined($max_level)) { $max_level = 0; } my $topdirs = $cfg->{svn_restricted_topdirs}; if (defined($topdirs)) { my @topdir_array = split (/\s*,\s*/, $topdirs); if (scalar(@topdir_array) > 0) { if (restrict_changes_in_topdirs ($min_level, $max_level, @topdir_array) <= -1) { exit (1); } } } if (restrict_read_only_files() <= -1) { exit (1); } #my $dbh = open_database ($cfg); #if (!defined($dbh)) #{ # printf (STDERR "Cannot open database - %s\n", $DBI::errstr); # exit (1); #} # #my $member; #my $commitable; #my $errstr; # #($member, $errstr) = is_project_member ( # $dbh, $cfg->{database_prefix}, $REPOBASE, $USER); #if ($member <= -1) #{ # print (STDERR "Cannot check membership - $errstr\n"); # close_database ($dbh); # exit (1); #} # #($commitable, $errstr) = is_project_commitable ( # $dbh, $cfg->{database_prefix}, $REPOBASE); #if ($commitable <= -1) #{ # print (STDERR "Cannot check commitability - $errstr\n"); # close_database ($dbh); # exit (1); #} # #close_database ($dbh); # #if ($member == 0) #{ # print (STDERR "$USER doesn't belong to the $REPOBASE project\n"); # exit (1); #} # #if ($commitable == 0) #{ # print (STDERR "The $REPOBASE project is not commitable\n"); # exit (1); #} # exit (0);