The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package SymbolicMode;

#
# $Id: SymbolicMode.pm,v 1.3 2004/08/05 14:18:01 cwest Exp $
#
# $Log: SymbolicMode.pm,v $
# Revision 1.3  2004/08/05 14:18:01  cwest
# cleanup, new version number on website
#
# Revision 1.1  2004/07/23 20:10:01  cwest
# initial import
#
# Revision 1.1  1999/03/07 12:03:54  abigail
# Initial revision
#
#

use strict;

sub mod ($$) {
    my $symbolic     = shift;
    my $file         = shift;

    # Initialization.
    # The 'user', 'group' and 'other' groups.
    my @ugo          = qw /u g o/;
    # Bit masks for '[sg]uid', 'sticky', 'read', 'write' and 'execute'.
    # Can't use qw // cause silly Perl doesn't know '2' is a number
    # when dealing with &= ~$bit.
    my %bits         = (s => 8, t => 8, r => 4, w => 2, x => 1);


    # For parsing.
    my $who_re       = '[augo]*';
    my $action_re    = '[-+=][rstwxXugo]*';


    # Find the current permissions. This is what we start with.
    my $mode         = sprintf "%04o" => (stat $file) [2] || 0;
    my $current      = substr $mode => -3;  # rwx permissions for ugo.

    my %perms;
       @perms {@ugo} = split // => $current;

    # Handle the suid, guid and sticky bits.
    # It looks like permission are 4 groups of 3 bits, groups for user,
    # group and others, and a group for the special flags, but they are
    # really 3 groups of 4 bits. Or maybe not. 
    # Anyway, this function is greatly simplified by treating them as
    # 3 4-bit groups. The highest bit will be "special" one. suid for
    # the users group, guid for the group group, and the sticky bit
    # for the others group.
    my $special      = substr $mode => -4, 1;
    my $bit          = 1;
    foreach my $c (reverse @ugo) {
        $perms {$c} |= 8 if $special & $bit;
        $bit <<= 1;
    }

    # Keep track of the original permissions.
    my %orig         = %perms;

    # Find the umask setting, and store the bits for each group
    # in a hash.
    my %umask;                              # umask bits.
       @umask {@ugo} = split // => sprintf "%03o" => umask;


    # Time to parse...
    foreach my $clause (split /,/ => $symbolic) {

        # Perhaps we should die if we can't parse it?
        return undef unless
            my ($who, $actions) =
                $clause =~ /^($who_re)((?:$action_re)+)$/o;
            # We would rather split the different actions out here,
            # but there doesn't seem to be a way to collect them.
            # /^($who_re)($action_re)+/ only gets the last one.
            # Now, we have to reparse in later.

        my %who;
        if ($who) {
            $who =~ s/a/ugo/;  # Ignore multiple 'a's.
            @who {split // => $who} = undef;
        }

        # @who will contain who these settings applies to.
        # if who isn't set, it might be masked with the umask,
        # hence, this isn't the final decision.
        # Maybe we don't need this.
        my @who = $who ? keys %who : @ugo;

        foreach my $action (split /(?=$action_re)/o => $actions) {
            # The first character has to be the operator.
            my $operator = substr $action, 0, 1;
            # And the rest are the permissions.
            my $perms    = substr $action, 1;

            # BSD documentation says 'X' is to be ignored unless
            # the operator is '-'. GNU, HP, SunOS and Solaris handle
            # '-' and '=', while OpenBSD ignores only '-'.
            # Solaris, HP and OpenBSD all turn a file with permission
            # 666 to a file with permission 000 if chmod =X is
            # is applied on it. SunOS and GNU act as if chmod = was
            # applied to it. I cannot find out what the reasoning
            # behind the choices of Solaris, HP and OpenBSD is.
            # GNU and SunOS seem to ignore the 'X', which, after
            # careful studying of the documentation seems to be
            # the right choice.
            # Therefore, remove any 'X' if the operator ain't '+';
            $perms =~ s/X+//g unless $operator eq '+';

            # If there are no permissions, things are simple.
            unless ($perms) {
                # Things like u+ and go- are ignored; only = makes sense.
                next unless $operator eq '=';
                           # Clear permissions on u= and go=.
                if ($who) {@perms {keys %who} = (0) x 3;}
                           # '=' is special. Sets permissions to the umask.
                else      {%perms             = %umask;}
                next;
            }

            # If we arrive here, $perms is a string.
            # We can iterate over the characters.
            foreach (split // => $perms) {
                if ($_ eq 'X') {
                    # We know the operator eq '+'.
                    # Permission of `X' is special. If used on a regular file,
                    # the execution bit will only be turned on if any of the
                    # execution bits of the _unmodified_ file are turned on.
                    # That is,
                    #      chmod 600 file; chmod u+x,a+X file;
                    # should result in the file having permission 700, not 711.
                    # GNU and SunOS get this wrong;
                    # Solaris, HP and OpenBSD get it right.
                    next unless -d $file || grep {$orig {$_} & 1} @ugo;
                    # Now, do as if it's an x.
                    $_ = 'x';
                }

                if (/[st]/) {
                    # BSD man page says operations on 's' and 't' are to
                    # be ignored if they operate only on the "other" group.
                    # GNU and HP happely accept 'o+t'. Sun rejects 'o+t',
                    # but also rejects 'g+t', accepting only 'u+t'.
                    # OpenBSD acceps both 'u+t' and 'g+t', ignoring 'o+t'.
                    # We do too.
                    # OpenBSD however, accepts 'o=t', clearing all the bits
                    # of the "other" group.
                    # We don't, as that doesn't make any sense, and doesn't
                    # confirm to the documentation.
                    next if $who =~ /^o+$/;
                }

                # Determine the $bit for the mask.
                my $bit = /[ugo]/ ? $orig {$_} & ~8 : $bits {$_};

                die "Weird permission `$_' found\n" unless defined $bit;
                                                            # Should not happen.

                # Determine the set on which to operate.
                my @set = $who ? @who : grep {!($umask {$_} & $bit)} @ugo;

                # If the permission is 's', don't operate on the other group.
                # Unless the operator was '='. But in that case, don't set
                # the 8 bit for 'other'.
                my $equal_s;
                if (/s/) {
                    if ($operator eq '=') {$equal_s = 1;}
                    else                  {@set     = grep {!/o/} @set or next;}
                }
                # If the permission is 't', only  operate on the other group;
                # regardless what the 'who' settings are.
                # Note that for a directory with permissions 1777, and a
                # umask of 002, a chmod =t on HP and Solaris turn the
                # permissions to 1000, GNU and SunOS turn the permissiosn
                # to 1020, while OpenBSD keeps 1777.
                /t/ and @set = qw /o/;

                # Apply.
                foreach my $s (@set) {
                    do {$perms {$s} |=  $bit; next} if $operator eq '+';
                    do {$perms {$s} &= ~$bit; next} if $operator eq '-';
                    do {$perms {$s}  =  $bit; next} if $operator eq '=';
                    die "Weird operator `$operator' found\n";
                                                            # Should not happen.
                }

                # Special case '=s'.
                $perms {o} &= ~$bit if $equal_s;
            }
        }
    }

    # Now, translate @perms to an *octal* number.

    # First, deal with the suid, guid, and sticky bits by collecting
    # the high bits of the ugo permissions.
    my $first = 0;
       $bit   = 1;
    for my $c (reverse @ugo) {
        if ($perms {$c} & 8) {$first |= $bit; $perms {$c} &= ~8;}
        $bit <<= 1;
    }

    join "" => $first, @perms {@ugo};
}

1;


__END__