#!/cad/perltools/ruby/ruby-x86-linux/bin/ruby -w # p4protect -- process Tensilica-custom perforce protection description files # Copyright (c) 2005, Tensilica Inc. # All rights reserved. # # Redistribution and use, with or without modification, are permitted provided # that the following conditions are met: # # - Redistributions must retain the above copyright notice, this list of # conditions, and the following disclaimer. # # - Modified software must be plainly marked as such, so as not to be # misrepresented as being the original software. # # - Neither the names of the copyright holders or their contributors, nor # any of their trademarks, may be used to endorse or promote products or # services derived from this software without specific prior written # permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # See `p4protect -h` for usage info. # # $Id: //depot/other/perforce/server/scripts/triggers/p4protect#34 $ "$Revision: #34 $" =~ /\d+/; PROGVERS = $&; PROGNAME = "p4protect"; UTNAME = "unit"; # magic unit test name require 'pp' # Allows pretty-printing like this: pp my_object require 'P4'; require File.dirname(File.expand_path($0)) + '/P4Triggers'; #require '../P4Triggers'; #require '/home/p4admin/bin/triggers/P4Triggers'; #$p4prog = "p4"; $p4 = nil; $debug = false; $errors_found = 0; $warnings_found = 0; $too_many_errors = false; $run_tests = false; $test_name = nil; $displayproc = nil; # These two are set by setup_users_groups(): $p4groups = {}; # hash of groupname => [list of users] $p4users = {}; # hash of username => substring from 'p4 users' output $meta_dirname = nil; META_FILENAME = 'prot_meta'; PermValues = {'DENY'=>0, 'RO'=>1, 'LOCK'=>2, 'SUBMIT'=>3}; # Output a message. # def displaymsg(msg) #$stderr.print "#{PROGNAME}: "; if $displayproc $displayproc.call(msg) else $stderr.print msg; end end def error(msg) return if $too_many_errors; displaymsg "ERROR: #{msg}\n"; $errors_found += 1; if $errors_found >= 20 displaymsg("ERROR: too many errors (#{$errors_found}), quitting"); $too_many_errors = true; end end def warn(msg) return if $too_many_errors; $warnings_found += 1; displaymsg "WARNING: #{msg}\n"; end # Parse a single line into tokens. # Handles double-quoted strings properly. # def parse_line(line, context) tokens = []; line.chomp!; while true line.sub!(/^\s+/, ''); # strip leading whitespace line.sub!(/^\#.*$/, ''); # strip out comments return tokens unless line.length > 0; if line.sub!(/^"/, '') # double-quoted? if ! line.sub!(/^([^"]*)"/, '') error("#{context}: unmatched quote"); end tokens.push($1); if line =~ /^\S/ error("#{context}: unexpected characters following terminating quote"); end else if ! line.sub!(/^\S+/, '') error("#{context}: internal error parsing '#{line}'"); end tokens.push($&); end # $stderr.print "Parsing <<#{line}>>\n"; # return() unless line ne ''; # skip empty lines # $stderr.print "Got <<#{line}>>\n"; # (line); # line.split(' '); end end # Append path to prefix, if path is a relative path. # If path is a full path, just return path. # def pathcat(prefix, path, needfullpath, context) return path if path =~ %r|^//|; # print "Path is '#{path}'\n"; error("#{context}: full paths begin with two forward slashes, relative paths begin with none;\n unexpected path '#{path}'") \ if path =~ %r|^/|; # Here, path is a relative path return prefix if path.length == 0; error("#{context}: full (rooted) pathname expected") \ if needfullpath and prefix !~ %r|^//|; return prefix + ((prefix =~ %r|/$|) ? '' : '/') + path; end # Check that path makes sense, and globs are valid: # def validatepath(path, context) # Catch a common error: FIXME could generalize this error("#{context}: superfluous './' prefix in path") if path =~ /^\.\//; # Verify that '...' is only at directory boundaries: mypath = path.dup; if mypath.gsub!(/\.\.\.([^\/])/, '.../*\1') or mypath.gsub!(/([^\/])\.\.\./, '\1*/...') # FIXME: make this an error: warn( "#{context}: tree wildcard '...' not at directory boundary:\n" + " '#{path}'\n" + " Unlike Perforce, we restrict '...' to the boundaries of the path or '/' characters,\n" + " for clarity. There is no loss of expressiveness. Consider rewriting your path as:\n" + " '#{mypath}'\n" + " (if that is what you intended)"); end # Verify that '/' is only doubled at the beginning: error("#{context}: separator '/' can only be doubled at start of path") if path =~ %r(.//); # Verify path does not end in '/': error("#{context}: path cannot end with '/' separator") if path =~ %r(/$); # Verify path does not begin with single '/': error("#{context}: path cannot begin with single '/' separator") if path =~ %r(^/[^/]) or path == '/'; # Verify '*' and '...' are not doubled (note: '*...' is useful and not flagged): error("#{context}: glob '*' unnecessarily doubled") if path =~ /\*\*/; error("#{context}: glob '...' unnecessarily doubled") if path =~ /\.\.\.\.\.\./; error("#{context}: glob '...' unnecessarily followed by '*'") if path =~ /\.\.\.\*/; # Verify only valid characters are present: # (NOTE: this ensures '@' is not in path, which is required for path_compare()) error("#{context}: unexpected single quote (') in path: use double quotes around the path to quote spaces") if path =~ /\'/; error("#{context}: unexpected character '#{$&}' in path") if path =~ /[^-a-zA-Z0-9_. \'\/*]/; end # Create (or modify) group 'g' to contain users/groups listed by name in 'list'. # def add_group(g, list, fromfile, context) # Check for ambiguous group/user names: if $p4users.detect {|u,v| $p4groups[u]} error("#{context}: group and user have the same name '#{u}'"); end # Test for invalid group members (Perforce does not check!): list.find_all {|w| !($p4users[w] or $p4groups[w]) }.each {|w| if fromfile # file-local g_xxx group? ug = (w =~ /^G_/) ? "group" : "user"; # member assumed a user or a group? error("#{context}: local group #{g}: unrecognized #{ug} '#{w}'"); list.delete(w); else # Perforce-defined G_xxx group? if w =~ /^G_/ # New Perforce groups can be created regardless of protections, # so treat non-existent groups as errors: error("#{context}: Perforce group #{g}: unrecognized group '#{w}'"); list.delete(w); else # Protections must specifically allow each new user before they can be created, # so we have to allow new users here, and assume their existence # for purposes of protection: warn("#{context}: Perforce group #{g}: assuming '#{w}' is a new user"); $p4users[w] = true; end end } # Expand groups within group members: seen = {} users = [] while w = list.shift next if seen[w]; seen[w] = 1; if $p4users[w] users.push(w) else #$stderr.print "SEE #{w} in #{g}\n"; list.concat($p4groups[w]) end end $p4groups[g] = users; # re-assign with expanded list end # Read-in user and group definitions from Perforce. # def setup_users_groups # Read in existing users. # (New users specified in groups will be added later.) $p4.run_users.each { |user| user.sub!(/^(\S+)\s.*$/, '\1'); if user =~ /^G_/ warn("#{PROGNAME}: ignoring user '#{user}' incorrectly prefixed with 'G_'"+ "\n (ask your Perforce administrator to fix this)"); next; end unless user =~ /^[a-zA-Z]\w*$/ warn("#{PROGNAME}: ignoring user '#{user}' not formed like an identifier"+ "\n (ask your Perforce administrator to fix this)"); next; end $p4users[user] = true; #$stderr.print "USER #{user}\n"; } # Read in existing groups. $p4.run_groups.each { |g| $p4groups[g] = []; #$stderr.print "GROUP '#{g}'\n"; unless g =~ /^G_/ warn("#{PROGNAME}: ignoring group '#{g}' not prefixed with 'G_'"+ "\n (ask your Perforce administrator to fix this)"); next; end unless g =~ /^G_\w+$/ warn("#{PROGNAME}: ignoring group '#{g}' containing non-identifier characters"+ "\n (ask your Perforce administrator to fix this)"); next; end process_user_group = false $p4.run_group('-o', g).join('').split("\n").each { |k| if (process_user_group) user_group = k.sub(/^\s+(\w+)/, '\1') $p4groups[g].push(user_group) if ($1) end process_user_group = true if (k =~ /^(Users|Subgroups)/) } } # Verify groups and expand subgroups within groups # (new users specified in groups are added here): $p4groups.each {|g,list| add_group(g, list, false, PROGNAME); } # Add '*' group: # FIXME: can't use "user *" in generated protections, so can't allow this for now: #add_group('*', $p4users.keys.sort, false, PROGNAME); # FIXME TODO instead of this, automatically collapse user lists to groups for shorter protection outputs #$p4groups.each {|g,list| #$stderr.print "GROUP #{g} = ", list.join("+"), " .\n"; #} #exit 0; end # Verify that Perforce account is logged in. # def verify_p4_login login_string = $p4.run_login('-s') #system("#{$p4prog} login -s > /dev/null 2>&1"); if (login_string.join("") !~ /ticket expires/) error("internal error: Perforce administrator account requires login\n(message is #{login_string})"); return false; end return true; # all ok end # Verify that the specified comma-separated groups/users are valid. # Remove redundant entries. # Return array of groups/users. # def checkwho(who, context) seen = {}; users = {}; list = []; whos = who.split(','); oopsie = false; while w = whos.shift next if seen[w]; seen[w] = 1; unless $p4groups[w] or $p4users[w] # oopsie: if w =~ /^G_/i error("#{context}: unrecognized group name '#{w}'\n" + " Valid group names are: " + $p4groups.keys.sort.join(", ") + "."); else error("#{context}: unrecognized user name '#{w}'\n" + " Valid user names are: " + $p4users.keys.sort.join(", ") + "."); end oopsie = true; next end if w =~ /^g_/ # file-local group name? whos = $p4groups[w] + whos; # expand to avoid non-Perforce names next; end list.push(w); if $p4users[w] users[w] = 1; else $p4groups[w].each {|u| users[u] = 1 } end end error("#{context}: empty list of users in #{context}") unless list.length > 0 and users.length > 0 and !oopsie; return [list, users]; end # Structures used to parse protections: # ProtEntry = Struct::new( :perm, # 'DENY', 'RO', 'LOCK', or 'SUBMIT' :dependent, # true (if '?' after perm keyword) or false :who, # [array of user and group names, expanded array of users] :path, # perforce *full* path name, possibly including globs ('...', '*') :startline, # line at which protection was declared :perm_overrides # privileges overriden by this entry (hash of perm => 1) ) ProtLevel = Struct::new( :prefix, # current 'with' prefix at this level :prots, # array of ProtEntry and ProtLevel structures :startline, # line at which scope started :endline # line at which scope ended ); # Remove identical (non-glob) characters from left of a and b. # Return true if only identical characters found, # false if different (non-glob) characters found. # (@ considered a glob) # def match_trim_left(a, b) while a != '' and b != '' and a !~ /^[*@]/ and b !~ /^[*@]/ #$stderr.print "a a=#{a} b=#{b} a0=#{a[0]} b0=#{b[0]}\n"; return false if a[0] != b[0]; # differ at start a.slice!(0,1); b.slice!(0,1); end return true; end # Same, for right side of a and b. def match_trim_right(a, b) while a != '' and b != '' and a !~ /[*@]$/ and b !~ /[*@]$/ #$stderr.print "b a=#{a} b=#{b}\n"; return false if a[-1] != b[-1]; # differ at end a.slice!(-1,1); b.slice!(-1,1); end return true; end # Given 'path' (Perforce path not containing globs) # and 'pattern' (Perforce path possibly containing globs), # return true if path matches pattern, false otherwise. # Globs include '*' and '...' (and '@' as synonym for '...', for internal use). # def path_compare_glob(path, pattern) pat = pattern.gsub(/\.\.\./, '@'); # normalize pat.gsub!(/([-.])/, '\\\1'); # escape '.' and '-' characters pat.gsub!(/\*/, '[^/]*'); # expand '*' globs pat.gsub!(/\@/, '.*'); # expand '...' globs re = Regexp.new(pat); # build regular expression return path =~ re; end # Check for overlap between two path components, a and b. # Neither has '...' nor '/'. But they can have '*'. # Returns: # nil no overlap # 0 a equals b # 1 a contains b # 2 b contains a # 3 partial overlap # def do_component_compare(a, b) return 0 if a == b; return nil if a !~ /[*@]/ and b !~ /[*@]/; # if no globs, they just differ aa = a.dup; bb = b.dup; # Compare non-glob characters at beginning and end: return nil unless match_trim_left(aa, bb); return nil unless match_trim_right(aa, bb); return 1 if aa == '*'; return 2 if bb == '*'; return path_compare_glob(aa, bb) ? 2 : nil if aa !~ /[*@]/; # globs in bb but not aa ? return path_compare_glob(bb, aa) ? 1 : nil if bb !~ /[*@]/; # globs in aa but not bb ? return 1 if aa == '*' + bb or aa == bb + '*' or aa == '*' + bb + '*'; return 2 if bb == '*' + aa or bb == aa + '*' or bb == '*' + aa + '*'; # Remaining cases are rather odd. Assume partial overlap for now. # This is a conservative, fail-safe return value: it may cause a valid # protection specification to be rejected, but should not (at least with # current code as of March 2005) cause an invalid / illegal protection # specification to be accepted. (This statement NOT formally verified) # FIXME/TODO: determine whether we can do any better. return 3; end def component_compare(a, b) x = do_component_compare(a,b); #$stderr.print "'#{a}' <=> '#{b}' is #{x ? x : 'nil'}\n"; return x; end # Check for overlap between two Perforce paths, a and b. # Each path may contain globs ('...' and '*'). # Returns: # nil no overlap # 0 a equals b # 1 a contains b # 2 b contains a # 3 partial overlap # In other words, if any overlap: # bit 0 is set (1) if a covers more than the overlap # bit 1 is set (2) if b covers more than the overlap # def do_path_compare(apath, bpath) return 0 if apath == bpath; # paths are identical aapath = apath.gsub(/\.\.\./, '@'); # convert '...' to '@' bbpath = bpath.gsub(/\.\.\./, '@'); a = aapath.split(%r(/+)); a[0] = '/' if aapath =~ %r(^//); b = bbpath.split(%r(/+)); b[0] = '/' if bbpath =~ %r(^//); overlap = 0; # Skip over identical initial entries (stop at any '...'): while a.length > 0 and b.length > 0 and a[0] !~ /@/ and b[0] !~ /@/ cmp = component_compare(a[0], b[0]); return nil unless cmp; # if different, whole path is different overlap |= cmp; # else track overlaps a.shift b.shift end # Skip over identical ending entries (stop at any '...'): while a.length > 0 and b.length > 0 and a[-1] !~ /@/ and b[-1] !~ /@/ cmp = component_compare(a[-1], b[-1]); return nil unless cmp; # if different, whole path is different overlap |= cmp; # else track overlaps a.pop b.pop end # Done if there are no '...': return overlap if a.length == 0 and b.length == 0; # If any one side is empty, paths are different, # because even with '...' non-empty implies at least one more directory: return nil if a.length == 0 or b.length == 0; # Skip over identical starting/ending characters. return nil unless match_trim_left(a[0], b[0]); return nil unless match_trim_right(a[-1], b[-1]); # Okay, now we get to some interesting / odd cases. # At this point, both arrays are non-empty. # At least one starts with a component containing '...', and # at least one ends with a component containing '...'. sega = a.join("/"); # full paths of what's left segb = b.join("/"); return path_compare_glob(sega, segb) ? overlap|2 : nil if sega !~ /[*@]/; # globs in b but not a ? return path_compare_glob(segb, sega) ? overlap|1 : nil if segb !~ /[*@]/; # globs in a but not b ? return (overlap | 1) if sega=='@'+segb or sega==segb+'@' or sega=='@'+segb+'@'; return (overlap | 2) if segb=='@'+sega or segb==sega+'@' or segb=='@'+sega+'@'; # Compare what's before any '...': aa = a[0].dup; a[0] = aa.sub!(/\**@.*$/, '*') ? $& : ''; # treat any '...' as '*' bb = b[0].dup; b[0] = bb.sub!(/\**@.*$/, '*') ? $& : ''; # treat any '...' as '*' cmp = component_compare(aa, bb); return nil unless cmp; # if different, whole path is different overlap |= cmp; # else track overlaps # Compare what's after any '...': aa = a[-1].dup; a[-1] = aa.sub!(/^.*@/, '*') ? $& : ''; # treat any '...' as '*' bb = b[-1].dup; b[-1] = bb.sub!(/^.*@/, '*') ? $& : ''; # treat any '...' as '*' cmp = component_compare(aa, bb); return nil unless cmp; # if different, whole path is different overlap |= cmp; # else track overlaps #$stderr.print "LEFT: '#{a.join('/')}' and '#{b.join('/')}' (overlap is #{overlap})\n"; sega = a.join("/"); # full paths of what's left segb = b.join("/"); return overlap if sega == segb; # if what remains is identical return (overlap | 1) if sega == '@'; # if a is just '...' it's a superset return (overlap | 2) if segb == '@'; # if b is just '...' it's a superset return (overlap | 1) if sega=='@'+segb or sega==segb+'@' or sega=='@'+segb+'@'; return (overlap | 2) if segb=='@'+sega or segb==sega+'@' or segb=='@'+sega+'@'; return 3 if a[0] =~ /^@/ and b[0] =~ /^@/ and a[-1] =~ /@$/ and b[-1] =~ /@$/; # if '...' around everything # Other cases are too complicated, just assume partial overlap. # This is a conservative, fail-safe return value: it may cause a valid # protection specification to be rejected, but should not (at least with # current code as of March 2005) cause an invalid / illegal protection # specification to be accepted. (This statement NOT formally verified) # It may cause arcane NO-OP statements to slip through unnoticed. # FIXME/TODO: determine whether we can do any better. # (Here it's clear we can do better, it's just a question of how far we # want to go. I know of patterns that fall through here [eg. see tests], # but none of them seem highly likely or interesting. -Marc 3/24/2005) warn("assuming partial overlap between '#{apath}' and '#{bpath}'") if $debug; # for debugging return 3; end def path_compare(apath, bpath) x = do_path_compare(apath, bpath); #$stderr.print "'#{apath}' vs '#{bpath}': #{x ? x : 'nil'}\n"; return x; end # Check for overlap between two protection entries, a and b, # both of type ProtEntry (see above). # Returns: # nil no overlap # 0 a equals b # 1 a contains b # 2 b contains a # 3 partial overlap # def prot_entry_compare(a, b) # Compare user hashes: a_users = a.who[1].keys.sort b_users = b.who[1].keys.sort common_users = a_users & b_users return nil if common_users.length == 0; woverlap = 0; woverlap |= 1 if a_users.length > common_users.length; woverlap |= 2 if b_users.length > common_users.length; # Compare paths: overlap = path_compare(a.path, b.path); return nil unless overlap; return (overlap | woverlap); end # Check for overlaps between protection entry 'prot' (declared in 'curlevel') # and (previous) protection entries in 'level'. # Called by parse_file() while parsing protection file. # # Returns true if check completed due to a superset entry found, false otherwise. # def level_overlap_check(prot, level, can_override, context) # Traverse protections in reverse order, and stop when we encounter a subsuming entry # (that completely supersets or equals 'prot'). # level.prots.reverse.each do |prev| if prev.class == ProtEntry cmp = prot_entry_compare(prot, prev); if cmp cmpmsg = ["equals", "is a subset of", "is a superset of", "intersects"] [cmp]; cmpdesc = "#{prev.path}(#{prev.who[0].join('+')}) " + "#{cmpmsg} " + "#{prot.path}(#{prot.who[0].join('+')})"; #" in scope that starts at line #{level.startline}" # Keep track of what 'prot' overlaps (eg. overrides) to help reduce output protection list: prot.perm_overrides[prev.perm] = cmp; # For debugging: #warn("#{context}: overlap with line #{prev.startline}:\n #{cmpdesc}"); # Check whether the overlap is okay, if not indicate why: if ! prev.dependent and prev.perm != prot.perm error( "#{context}: conflicting overlap with independent line #{prev.startline}:" + "\n #{cmpdesc}"); elsif ! can_override # and prev.perm != prot.perm # FIXME: do we allow this 'and' clause or not? error( "#{context}: overlap with line #{prev.startline} whose scope has ended:" + "\n #{cmpdesc}"); elsif cmp == 0 or cmp == 1 error( "#{context}: overlap makes line #{prev.startline} a NO-OP:" + "\n #{cmpdesc}"); elsif cmp == 2 and prev.perm == prot.perm error( "#{context}: is a NO-OP subset of line #{prev.startline} with same permissions:" + "\n #{cmpdesc}"); elsif cmp == 2 # Entry 'prev' is a superset of 'prot', with different permissions. # This is normal and okay. At this point however, there is no need # need to check 'prot' against entries earlier than 'prev' because we've # already made relevant checks when checking 'prev'. # Indeed, the search must stop here, otherwise we might hit an entry earlier # than 'prev' that is a superset of 'prev' but with same permissions as 'prot' # which is also normal but which the above check would flag as an error. return true; else # case 3 (partial overlap / intersection) with different or same permissions, # where 'prev' is dependent ('?' specified), are normal and okay. end end else # prev.class == ProtLevel #$stderr.print "class is #{prev.class}.\n"; # Recurse in closed scope. These cannot be overridden: return true if level_overlap_check(prot, prev, false, context); end end return false; end # Verify whether a protection entry complies with meta protections. # Returns true if okay, false (with error already displayed) otherwise. # def validate_prot(prot, metalist, protfilename, context) unless metalist.length > 0 error("#{context}: missing meta protection information"); return false; end metalist.each {|m| next unless m.perm == protfilename; cmp = prot_entry_compare(m, prot); return true if cmp == 0 or cmp == 1; # okay if prot subset of meta entry } error("#{context}: entry not allowed by meta protection file:\n" + " #{prot.perm} #{prot.who[0].join(',')} #{prot.path}"); return false; end # Return list of users/groups that correspond to '*' for given path and protection file. # def allowed_users(metalist, protfilename, path) seen = {} metalist.each {|m| next unless m.perm == protfilename; overlap = path_compare(m.path, path); next unless overlap and (overlap == 0 or overlap == 1); # Path is subset, add users/groups: m.who[0].each {|w| seen[w] = 1;} } return seen.keys.sort; end # Parse a meta protection entry in the master protection file. # def parse_meta_file(file, filename, metalist, lineref) line = lineref[0]; startline = line; metalev = ProtLevel.new('', [], startline); # local use only while file.length > 0 line += 1; tokens = parse_line(file.shift, line); next if tokens.length == 0; # skip empty lines context = "#{filename} line #{line}"; case tokens[0] when 'end' lineref[0] = line; return false; # no errors when 'meta', 'with', '^', 'DENY', 'RO', 'LOCK', 'SUBMIT' error("#{context}: unexpected token '#{tokens[0]}' within 'meta' block"); else # error("#{context}: expected exactly three parameters for meta protection entry:\n , , ") \ unless tokens.length == 3; prot = ProtEntry.new(tokens[0], false, tokens[1], tokens[2], line, {}); if prot.perm == '^' or prot.who == '^' or prot.path == '^' error("#{context}: repeat character '^' not allowed in meta protection line"); end validatepath(prot.path, context); prot.path = pathcat('', prot.path, true, context); #prot.who = $p4users.keys.join(",") if prot.who == '*'; prot.who = checkwho(prot.who, context); # For debugging: printf "%3d: meta %7s %16s %s\n", line, prot.perm, prot.who[0].join(","), prot.path if $debug; metalev.prots = metalist; level_overlap_check(prot, metalev, true, context); metalist.push(prot); end end error("#{context}: missing 'end' for 'meta' starting at line #{startline}"); lineref[0] = line; return true; # unexpected end of file end # Parse a protection file. # Returns parsed protection in the form of a ProtLevel structure, # or nil on error. # def parse_file(file, protfilename, metalist, is_meta) curlevel = ProtLevel.new('', [], 1); levels = []; # entry for each level (depth) currently being parsed below curlevel lastprot = []; prefix = ''; line = 0; # line number while file.length > 0 line += 1; tokens = parse_line(file.shift, line); next if tokens.length == 0; # skip empty lines context = "#{protfilename} line #{line}"; # printf "%3d:", line; print " ", tokens.join(', '), ".\n"; case tokens[0] when 'begin' error("#{context}: unexpected text '#{tokens[1]}' after 'begin'") \ unless tokens.length == 1 or (tokens.length == 3 and tokens[1] == 'with'); levels.push(curlevel); curlevel = ProtLevel.new(prefix, [], line, nil); if tokens.length == 3 prefix = pathcat(prefix, tokens[2], true, context); end when 'meta' unless is_meta error("#{context}: meta keyword only allowed in meta (master) protection file"); return nil; end unless levels.length == 0 error("#{context}: meta keyword only allowed at top-level"); return nil; end last if parse_meta_file(file, protfilename, metalist, ref = [line]); line = ref[0]; when 'end' if levels.length == 0 error("#{context}: unmatched 'end'"); end oldlevel = curlevel curlevel = levels.pop; oldlevel.endline = line curlevel.prots.push(oldlevel); prefix = oldlevel.prefix; # restore prefix when /^(\^|DENY|RO|LOCK|SUBMIT)(\??)$/ perm = $1; dependent = ($2 == '?'); error("#{context}: expected exactly two parameters after #{perm} keyword") \ unless tokens.length == 3; prot = ProtEntry.new(perm, dependent, tokens[1], tokens[2], line, {}); prot.path = '' if prot.path == '.'; # this is what pathcat expects # Deal with '^' character: if lastprot.length == 0 and (prot.perm == '^' or prot.who == '^' or prot.path == '^') error("#{context}: repeat character '^' not allowed in first protection line"); end prot.perm = lastprot.perm if prot.perm == '^'; prot.who = lastprot.who if prot.who == '^'; prot.path = lastprot.path if prot.path == '^'; lastprot = prot.dup; # Other expansions: validatepath(prot.path, context); prot.path = pathcat(prefix, prot.path, true, context); if prot.who == '*' prot.who = allowed_users(metalist, protfilename, prot.path).join(","); end prot.who = checkwho(prot.who, context); # Check against meta protections: validate_prot(prot, metalist, protfilename, context) or return nil; printf "%3d: %7s %16s %s\n", line, prot.perm, prot.who[0].join(","), prot.path if $debug; # Check overlaps: unless level_overlap_check(prot, curlevel, true, context) levels.reverse.each {|lev| break if level_overlap_check(prot, lev, true, context); } end curlevel.prots.push(prot); when 'group' error("#{context}: expected exactly two parameters (group name, and comma-separate member list) after 'group' keyword") \ unless tokens.length == 3; groupname,members = tokens[1],tokens[2]; error("#{context}: file-local group name '#{groupname}' invalid or does not start with 'g_' (lowercase)") unless groupname =~ /^g_\w+$/; error("#{context}: file-local group name '#{groupname}' already exists") if $p4groups[groupname] or $p4users[groupname]; add_group(groupname, members.split(","), true, context); else error("#{context}: unrecognized token '#{tokens[0]}'"); end end $p4groups.delete_if {|g,m| g =~ /^g_/ }; # remove file-local groups if levels.length > 0 error("#{context}: missing 'end' for 'begin' at line #{curlevel.startline}"); end curlevel.endline = line; if $errors_found > 0 error("#{protfilename}: #{$errors_found} error(s) found"); return nil; end return curlevel; end # Construct a single Perforce protection entry (line). # 'who' is a single user or group (not a list). # def construct_protection_line(perm,who,path) user_group = ($p4groups[who] && who != '*') ? "group" : "user"; line = " #{perm} #{user_group} #{who} * "; # Only quote if needed: if path =~ / / line += "\"#{path}\"" else line += path end return line + "\n" end # Construct new Perforce protection list from sub-parsetree # (as returned by parse_file()). # def construct_protections_level(parsetree) output = ""; parsetree.prots.each do |p| if p.class == ProtEntry p.who[0].each do |w| case p.perm when 'DENY' then p4perm = 'list'; prefix = '-'; predeny = false; review = false; when 'RO' then p4perm = 'read'; prefix = ''; predeny = true; review = true; when 'LOCK' then p4perm = 'open'; prefix = ''; predeny = true; review = true; when 'SUBMIT' then p4perm = 'write'; prefix = ''; predeny = false; review = true; else error("oopsie, unrecognized permission #{p.perm}."); end if predeny and p.perm_overrides.keys.find {|pp| PermValues[pp] > PermValues[p.perm] } output += construct_protection_line("list", w, "-"+p.path); else predeny = false; end if review and (p.perm_overrides.keys.find {|pp| pp == 'DENY' } or ! p.perm_overrides.values.find {|pp| pp == 2 } or predeny) output += construct_protection_line("review", w, prefix + p.path); end output += construct_protection_line(p4perm, w, prefix + p.path); end else output += construct_protections_level(p); end end return output; end # Construct new Perforce protection list from a single hierarchical parsetree. # def construct_protections(protfile, parsetree, metalist) output = ""; # First, deny all that this file covers, per the meta file: metalist.each do |m| next unless m.perm == protfile; m.who[0].each do |w| output += construct_protection_line("list", w, "-" + m.path); end end # Then go through each protection entry: output += construct_protections_level(parsetree); return output; end ########################### TRIGGER CLASS # The trigger class, built using Perforce's P4Trigger trigger-wrapper class. # The main method in here is validate() which # is invoked from the super-class' parse_change() method. # class MetaProtectTrigger < P4Trigger # Constructor. def initialize() # @foo = ... super() end # Get protection file contents def populate(protfile,file) if file # It's a new/edited file. if file.revisions[0].action == 'delete' message("\n Meta protections refer to a file being deleted.\n"); return nil; end metachg = "@=#{change.change}"; else # It's an existing file. metachg = "#head"; end # FIXME: Using P4 will provide better error handling here: #message("*** retrieving #{protfile}#{metachg}.\n"); # FIXME - fix $p4 to handle -q. #contents = `#{$p4prog} print -q #{$meta_dirname}/#{protfile}#{metachg}`; contents = $p4.run_print('-q', "#{$meta_dirname}/#{protfile}#{metachg}"); contents = contents.join(""); unless(contents) message("\n Error #{$?} retrieving #{protfile}#{metachg}.\n"); return nil; end if contents == '' message("\n Empty file or error retrieving #{protfile}#{metachg}.\n"); return nil; end return contents; end # Validate submission. This is the main trigger method. # def my_validate() # Get a list of protection files in this submission: @newfiles = {} change.each_file do |file| unless file.depot_file =~ %r{^#{$meta_dirname}/(\w+)$} message( "\n Please restrict your submission to files in the"+ "\n #{$meta_dirname} directory. You included:"+ "\n #{file.depot_file}"+ "\n which is not allowed in a protection update."+ "\n" ) return false; # reject submission end protname = $1 @newfiles[protname] = file; end # Process meta protection file: metafile = populate(META_FILENAME,@newfiles[META_FILENAME]); # get prot_meta file contents return false unless metafile; # else reject submission #$displayproc = proc {|msg| print "FOO: #{msg}";} $displayproc = proc {|msg| message(msg)} metalist = []; parsetrees = {}; parselist = []; unless parse = parse_file(metafile.chomp.split(/\n/), META_FILENAME, metalist, true) return false; # reject submission end parsetrees[META_FILENAME] = parse; parselist.push(META_FILENAME); # Process all files referenced by the meta protection file: metalist.each do |m| protfile = m.perm; next if parsetrees[protfile]; # skip if already parsed contents = populate(protfile,@newfiles[protfile]); # get contents #message("*** got #{protfile}\n"); return false unless contents; # else reject submission unless parse = parse_file(contents.chomp.split(/\n/), protfile, metalist, false) return false; # reject submission end parsetrees[protfile] = parse; parselist.push(protfile); end # Now construct new Perforce protection list from parsetrees[] protections = <<__ENDPROT__; Protections: super user p4admin * //comment/AUTOGENERATED_#{Time.now.asctime.gsub(/ /, '_')} __ENDPROT__ # FIXME: used to have "list user * * -//..." in header but bug in p4 makes this enable arbitrary user creation! parselist.each do |protfile| protections += construct_protections(protfile, parsetrees[protfile], metalist); end protections += <<__ENDPROT__; review user p4admin * //... super user p4admin * //... __ENDPROT__ if change.desc =~ /REJECTTEST/ message("Submission rejected by submitter request (REJECTTEST keyword found).\n"); return false; # submission rejected end if $warnings_found > 0 and change.desc !~ /IGNOREWARN/ message( "\n"+ "Submission rejected because of warnings (so that you can see them).\n"+ "Please insert the keyword IGNOREWARN anywhere in your change description\n"+ "to ignore these warnings and let your submission go through. E.g. do:\n"+ "\n"+ " p4 change #{change.change}\n"+ " (add 'IGNOREWARN' to the Description field)\n"+ " p4 submit -c #{change.change}\n" ); return false; # submission rejected end ################################################## # COMMIT POINT ################################################## message("*** constructed protections\n"); $p4.input(protections) $p4.run_protect('-i') unless $run_tests File.open("/home/p4admin/bin/triggers/last_p4protect_output", "w") do |f| f.print protections; end end message( "Submission rejected because... well, just because.\n" ) #return false; # submission rejected return true; # submission accepted end # Invoke my_validate() above, and try to unlock files on failure: def validate() valid = my_validate(); unless valid # TEST: when rejecting, unlock all files in the change # to avoid content-trigger lock bug: change.each_file do |file| $p4.run_unlock('-f', file.depot_file); # if file.depot_file =~ %r{^#{$meta_dirname}/(\w+)$}; end end return valid; end end ########################### ARGUMENT PARSING def usage print <<__USAGE__; #{PROGNAME} version #{PROGVERS} - process custom protection description files usage: #{PROGNAME} [options] where options are: -h display this message and exit -t tname run test , "#{UTNAME}" means run unit tests -d turn on debugging messages __USAGE__ exit 0; end def process_cmd_args require 'getoptlong'; opts = GetoptLong.new( ["--help", "-h", GetoptLong::NO_ARGUMENT ], ["--test", "-t", GetoptLong::REQUIRED_ARGUMENT ], ["--debug", "-d", GetoptLong::NO_ARGUMENT ] ); opts.each do |opt,arg| case opt when '--help' usage(); when '--debug' $debug = true; when '--test' $run_tests = true; $test_name = arg end end ARGV.each do |arg| # ... process filename argument ... end end ########################### MAIN PROGRAM ################################ def manual_main verify_p4_login or exit 1; setup_users_groups; metafile = [ 'meta', ' prot_dev G_allusers //depot/main/...', ' prot_dev G_allusers //depot/dev/...', 'end' ]; metalist = []; parsetree1 = parse_file(metafile, META_FILENAME, metalist, true); parsetree2 = parse_file($stdin.readlines, 'prot_dev', metalist, false); #process_paths(parsetree1, parsetree2); end def trigger_main $stderr = $stdout; # this really saves a lot of pain getting errors back to the submitter :-) print "\n\n"; # for more nicely readable submission reject messages $displayproc = proc {|msg| print "#{msg}";} verify_p4_login or exit 1; setup_users_groups; trig = MetaProtectTrigger.new; return( trig.parse_change( ARGV.shift ) ) end ########################### CALL MAIN ################################ # When called via trigger: if ARGV.length == 1 and ARGV[0] =~ /^\d+$/ $meta_dirname = '//depot/meta/prot'; $p4 = P4.new $p4.connect exit(trigger_main()) end process_cmd_args; # When called from command line: unless ($run_tests) $meta_dirname = '//depot/meta/prot'; $p4 = P4.new manual_main; # MAIN PROGRAM exit 0; # done end ########################### UNIT TESTS ################################ # Override methods in P4 wrapper to get values from the current test. # Ruby rocks! require 'P4' require 'test/unit' # FIXME - make sure these generate assertions $saved_assertions = [] class P4 @saved_input = nil def xt_unit_test_init(testdir) @testdir = testdir #p "unit_test_init called with #{@testdir}" require @testdir + '/setup.rb' end def xt_strip_comments_and_whitespace(array_in, gold=false) array_out = [] array_in.each { |line| line.gsub!(/\s+/, ' ') line.sub!(/^ *\#.*$/, '')if gold line.gsub!(/\s+$/, '') next if line =~ /^\s*$/ next if line =~ %r{//comment/} array_out.push(line) } return array_out end def xt_write_file(fname, contents) print("Writing out #{fname}\n") File.open(fname, "w") { |fh| fh.print contents } end def xt_process_p4_protect if (Setup::Ut_expected_test_return_status == 0) goldfile = @testdir + '/gold_p4_protect' goldfile_contents = nil if File.readable?(goldfile) fh = File.new(goldfile) goldfile_contents = xt_strip_comments_and_whitespace(fh.readlines, true).join("\n") else error("Could not read file #{goldfile}\n") end xt_write_file(@testdir + '/actual_p4_protect', @saved_input) @stripped_saved_input = xt_strip_comments_and_whitespace(@saved_input.split("\n")). join("\n") if (goldfile_contents == @stripped_saved_input) print "Goldfile matches generated 'p4 protect -i'\n" else error("Goldfile does not match 'p4 protect -i'") xt_write_file(@testdir + '/actual_p4_protect.strip', @stripped_saved_input) xt_write_file(@testdir + '/gold_p4_protect.strip', goldfile_contents) end else message = "protect -i unexpectedly executed in testcase" error(message) # FIXME - make sure this causes assertion in caller $saved_assertions.push(message) end end alias xt_original_input input def input(*args) if args.length == 1 @saved_input = args[0] else error("only support 'input(the_input)") end end alias xt_original_run run # This fake version of P4:run supports 'run' and 'run_XXX' syntax for P4 # commands, but not fetch_group. def run(*args) orig_args = args.clone cmd = args.shift # run_XXX form puts all args into an array... args.flatten! # FIXME - fail commands unless connect/login called first. # FIXME test that disconnect called before script exits? case (cmd) when 'connect' print "Connecting to nowhere...\n" when 'login' if (args.length == 1 and args[0] == '-s') return ["User foobar ticket expires in 1 hour 3 minutes\n"] else error("only supporting 'login -s'") end when 'users' error("users takes no args") unless args.length == 0 return Setup::Ut_p4_users when 'groups' error("groups takes no args") unless args.length == 0 return Setup::Ut_p4_group_hash.keys when 'group' if (args.length == 2 and args[0] == '-o') group_form = Setup::Ut_p4_group_hash[args[1]] error("non-existent group #{args[1]}") unless group_form return [group_form] else error("only supporting 'group -o '") end when 'print' if (args.length == 2 and args[0] == '-q') full_fname = args[1].sub(/#.*/, '') #p full_fname if File.readable?(full_fname) fh = File.new(full_fname) return [fh.read, ""] else error("Could not read file #{full_fname}\n") return nil end else error("only support 'print -q '") return nil end when 'protect' if (args.length == 1 and args[0] == '-i') xt_process_p4_protect else error("only supporting 'protect -i '") end when 'describe' # FIXME - ignoring changenum OK? if (args.length == 2 and args[0] == '-s') return [Setup::Ut_p4_describe] else error("only supporting 'describe -s '") end else error("Fake P4 cmd #{orig_args.join(':')}, unimplemented\n") end end end # Run a single protection test using a set of protection files, a metafile, and # a setup.rb file. class Check_protection_files < Test::Unit::TestCase # :nodoc: #P4_PROTECTION_TESTS_DIR = "p4protect_tests" def test_protection_file_group return if $test_name == UTNAME $displayproc = proc {|msg| } # Disable error msg printing # FIXME - support only one test, and get it from --test # this is how the script is actually used #prot_file_tests = Dir.glob(P4_PROTECTION_TESTS_DIR + "/*") #if prot_file_tests.length == 0 # error("Could not find prot_file tests") #end print "p4protect test: #{$test_name}\n" unless (test(?d, $test_name)) fail("Could not find test directory #{$test_name}") end $p4 = P4.new $meta_dirname = $test_name $p4.xt_unit_test_init($test_name) # FIXME - pushing on bogus change number; instead, put in test ARGV.push(1) status = (trigger_main != 0 || $errors_found > 0) ? 1 : 0; assert_equal(Setup::Ut_expected_test_return_status, status) end end class Check_validatepath < Test::Unit::TestCase # :nodoc: # Helper function for test_star_star def star_star_helper(path, expected=1) validatepath(path, path) assert_equal(expected, $errors_found) $errors_found = 0; end # Verify validatepath function def test_star_star return if $test_name != UTNAME print "p4protect test: #{$test_name}\n" $errors_found = 0; save_displayproc = $displayproc; $displayproc = proc {|msg| } # Disable error msg printing star_star_helper('//this-is okay_/.*...txt', 0) star_star_helper('relative too', 0) star_star_helper('like/this', 0) star_star_helper('//...a/***') star_star_helper('//a**') star_star_helper('//aa***bb') star_star_helper('//a......b') star_star_helper('//a...*...b') star_star_helper('//a...*b') star_star_helper('//a@b') star_star_helper('//a**b') star_star_helper('//a#b') star_star_helper('//a+b') star_star_helper('//a**b/cde/e**f') star_star_helper('//a*b/cde/e********f') star_star_helper('//donot//doubleslashes') star_star_helper('//orend/withone/') star_star_helper('/what_s_that_root') star_star_helper("//what's this") star_star_helper('//...**/c', 2) $displayproc = save_displayproc; # restore printing end end # Class for checking def path_compare(apath, bpath) # FIXME - test punctuation more fully? class Check_path_compare < Test::Unit::TestCase # :nodoc: def test_no_overlaps # Paths have no overlap ==> nil return if $test_name != UTNAME assert_equal(nil, path_compare( '//a', '//b')) assert_equal(nil, path_compare( '//a', '//A')) assert_equal(nil, path_compare( '//a*', '//b*')) assert_equal(nil, path_compare( '//a...', '//b...')) assert_equal(nil, path_compare( '//aa', '//ab')) assert_equal(nil, path_compare( '//a*a', '//a*b')) assert_equal(nil, path_compare( '//a...a', '//a...b')) assert_equal(nil, path_compare( '//depot/...foo/o*k', '//depot/...bar/o*k')) assert_equal(nil, path_compare( '//depot/.../foo', '//depot/.../afoo')) assert_equal(nil, path_compare( '//depot/foo', '//depot/*/foo')) assert_equal(nil, path_compare( '//depot/...*/foo', '//depot/foo')) assert_equal(nil, path_compare( '//depot/*.../foo', '//depot/foo')) assert_equal(nil, path_compare( '//depot/...*.../foo', '//depot/foo')) assert_equal(nil, path_compare( '//depot/.../a*foo', '//depot/.../b*foo')) assert_equal(nil, path_compare( '//depot/.../...done', '//depot/welldone')) assert_equal(nil, path_compare( '//depot/welldone', '//depot/.../...done')) assert_equal(nil, path_compare( '//depot/...xy...', '//depot/axtz')) assert_equal(nil, path_compare( '//depot/axtz', '//depot/...xy...')) assert_equal(nil, path_compare( '//depot/*xy*', '//depot/axtz')) assert_equal(nil, path_compare( '//depot/axtz', '//depot/*xy*')) x = path_compare( '//depot/.../*xy*...', '//depot/*t*/zyxel/foobar') assert((x == nil or x == 3)) # must be nil, currently interpreted as 3 (tricky case) x = path_compare( '//depot/...xz.../...', '//depot/abxyz/*foo') assert(x == nil || x == 3) # must be nil, currently interpreted as 3 (tricky case) end def test_paths_identical # Paths are identical ==> 0 return if $test_name != UTNAME assert_equal(0, path_compare( '//a', '//a')) assert_equal(0, path_compare( '//xx', '//xx')) assert_equal(0, path_compare( '//a/bb/ccc/dddd/eeeee/aoeuhts', '//a/bb/ccc/dddd/eeeee/aoeuhts')) assert_equal(0, path_compare( '//1246eygcEOquReaRSwk-_<>', '//1246eygcEOquReaRSwk-_<>')) assert_equal(0, path_compare( '//...foo*/.../a*b/....pdf', '//...foo*/.../a*b/....pdf')) end def test_path_a_contains_b # Path a contains b ==> 1 return if $test_name != UTNAME assert_equal(1, path_compare( '//depot/...foo', '//depot/*foo')) assert_equal(1, path_compare( '//depot/...foo', '//depot/foo')) assert_equal(1, path_compare( '//depot/...foo', '//depot/...afoo')) assert_equal(1, path_compare( '//depot/...foo', '//depot/...bar...foo')) assert_equal(1, path_compare( '//depot/aa*bb/foo', '//depot/aabb/foo')) assert_equal(1, path_compare( '//*a/.../b*c', '//a/.../b*c')) assert_equal(1, path_compare( '//*a/.../b...c', '//a/.../b...c')) assert_equal(1, path_compare( '//a/.../b*c*', '//a/.../b*c')) assert_equal(1, path_compare( '//...a/.../b*c', '//a/.../b*c')) assert_equal(1, path_compare( '//...a/.../b...c', '//a/.../b...c')) assert_equal(1, path_compare( '//a/.../b*c...', '//a/.../b*c')) assert_equal(1, path_compare( '//a/.../b...c...', '//a/.../b...c')) assert_equal(1, path_compare( '//depot/...xy...', '//depot/axyz')) assert_equal(1, path_compare( '//depot/*xy*', '//depot/axyz')) x = path_compare( '//depot/.../*yx*...', '//depot/*t*/zyxel/foobar') assert(x == 1 || x == 3) # must be 1, currently interpreted as 3 (tricky case) x = path_compare( '//depot/...xy.../...', '//depot/abxyz/*foo') assert(x == 1 || x == 3) # must be 1, currently interpreted as 3 (tricky case) end def test_path_b_contains_a # Path b contains a ==> 2 return if $test_name != UTNAME assert_equal(2, path_compare( '//depot/foo', '//depot/...foo')) assert_equal(2, path_compare( '//depot/...afoo', '//depot/...foo')) assert_equal(2, path_compare( '//depot/...bar...foo', '//depot/...foo')) assert_equal(2, path_compare( '//depot/aabb/foo', '//depot/aa*bb/foo')) assert_equal(2, path_compare( '//a/.../b*c', '//*a/.../b*c')) assert_equal(2, path_compare( '//a/.../b...c', '//*a/.../b...c')) assert_equal(2, path_compare( '//a/.../b*c', '//a/.../b*c*')) assert_equal(2, path_compare( '//a/.../b...c', '//a/.../b...c*')) assert_equal(2, path_compare( '//a/.../b*c', '//...a/.../b*c')) assert_equal(2, path_compare( '//a/.../b...c', '//...a/.../b...c')) assert_equal(2, path_compare( '//a/.../b*c', '//a/.../b*c...')) assert_equal(2, path_compare( '//a/.../b...c', '//a/.../b...c...')) assert_equal(2, path_compare( '//depot/axyz', '//depot/...xy...')) assert_equal(2, path_compare( '//depot/axyz', '//depot/*xy*')) x = path_compare( '//depot/*t*/zyxel/foobar', '//depot/.../*yx*...') assert(x == 2 || x == 3) # must be 2, currently interpreted as 3 (tricky case) x = path_compare( '//depot/abxyz/*foo', '//depot/...xy.../...') assert(x == 2 || x == 3) # must be 2, currently interpreted as 3 (tricky case) end def test_paths_partially_overlap # Path a, b partially overlap ==> 3 return if $test_name != UTNAME assert_equal(3, path_compare( '//a/*', '//*/c')) assert_equal(3, path_compare( '//a/b/*', '//a/*/c')) assert_equal(3, path_compare( '//*/b/*', '//*/*/c')) assert_equal(3, path_compare( '//a/...*', '//*/...b')) assert_equal(3, path_compare( '//a/*...', '//*/c...')) assert_equal(3, path_compare( '//a.../*', '//*.../c')) assert_equal(3, path_compare( '//...a/*', '//...*/c')) assert_equal(3, path_compare( '//depot/...abc...', '//depot/...def...')) assert_equal(3, path_compare( '//depot/...abc*', '//depot/...def...')) assert_equal(3, path_compare( '//depot/...def...', '//depot/...abc*')) assert_equal(3, path_compare( '//depot/...abc*', '//depot/*def...')) assert_equal(3, path_compare( '//depot/*def...', '//depot/...abc*')) assert_equal(3, path_compare( '//depot/abc*', '//depot/*def...')) assert_equal(3, path_compare( '//depot/*def...', '//depot/abc*')) assert_equal(3, path_compare( '//depot/...xy.../zafoo', '//depot/abxyz/*foo')) assert_equal(3, path_compare( '//depot/abxyz/*foo', '//depot/...xy.../zafoo')) assert_equal(3, path_compare( '//depot/.../*yx*...foo*', '//depot/*t*/zyxel/foobar...')) # currently with warning end end