Scott M. Mcdermott

UNIX Systems & Network Administrator
available for contract or salaried positions

cvs-permfix-commit.c

/*
 * CVS permissions fix: commits
 *
 * SUMMARY
 *
 *     Run as loginfo script for all CVS operations.
 *     Propagates ACL entries of parent/container
 *     directories to any subfiles or subdirectories upon
 *     commit.  NOTE: does NOT handle tags.  See the
 *     companion program for that.
 *
 * EXECUTION
 *
 *     Run as a CVS loginfo filter with %{,,,,,sv} as the
 *     argument format, using regexp ALL.
 *
 * DETAILS
 *
 *     This program is a fix for CVS permissions problems we
 *     are seeing where added files/directories do not in
 *     some circumstances (which are as yet unknown and not
 *     reproducible) inherit the default ACL from the parent
 *     directory.
 *
 *     Instead of using default ACLs, we are going to
 *     instead always copy the access ACL from the parent
 *     directory, for any new file or directory, or any
 *     modified file.  Even though the permission problems
 *     we are trying to fix seem only to show up with adds,
 *     it is necessary to reset permsions for *all* CVS
 *     operations if we are not using default ACLs.  This is
 *     because of how CVS modifies files: it makes a
 *     temporary file with a different name in its
 *     destination directory, and then moves it into place
 *     under the real name of the file it is replacing.
 *     Without the use of default ACLs in the destination
 *     directories, this will result in ACL-less files for
 *     modifies, in addition to adds.
 *
 *     For new directories, we will exactly copy the ACL of
 *     the parent directory.
 *
 *     For new or modified files, we will copy the ACL of the
 *     parent directory, and remove all write bits.  These
 *     are never needed because CVS does not ever modify
 *     files in-place; it replaces them with new copies of
 *     the file upon commit.
 *
 *     If the file's owner has the execute bit set before we
 *     consider the parent directory ACL, we can conclude
 *     that we should set at least one execute bit in the
 *     new ACL for the file.  In this case we give execute
 *     permission to any ACL that also grants read access.
 *     Other than that, we strip the execute bits from the
 *     set of those copied from the parent.  This heuristic
 *     might not be exactly right but should work well
 *     enough.
 *
 * ASSUMPTIONS
 *
 *     Not too much care was taken to check for input.
 *     Since it's coming from CVS and we aren't running
 *     privileged, this shouldn't be too big a danger.
 *
 *     Does not attempt to handle allocation failures.  If
 *     we enter real OOM state on our CVS server and have
 *     run out of swap, we have much bigger problems.
 *
 *     Filename separator used by loginfo is hardcoded.  It
 *     is necessary to use one because otherwise, both
 *     filenames and directories with spaces will cause
 *     serious problems: there is no way to tell where the
 *     directory ends and the filenames begin, and there is
 *     no way to determine where the next file begins in a
 *     list of files.
 *
 *     Note that using an unsupported character in the
 *     format string seems to always result in a comma in
 *     the output, so we just use commas directly E.g.,
 *     using `%{.....%sv}' will actually yield a string:
 *     `,,,,,filename,newversion'.
 *
 *     The filename separator cannot collide with actual
 *     file or directory names, or we screw up.  It's
 *     actually impossible to use a separator that can't
 *     also be used in a filename, since one can easily make
 *     a filename with any number of contiguous commas.
 *     Using filenames with the separator string in them
 *     will screw up (although it should only screw up for
 *     those particular files and continue to run ok for the
 *     remainder of the files given by loginfo).
 *
 * BUGS
 *
 *     CVS does not seem to check the result of executing
 *     the loginfo script, so nothing interrupts CVS
 *     operation if we fail.  I'm almost thinking we should
 *     raise a SIGTERM to our parent upon failure, or
 *     something like that.  But thankfully, we do get
 *     stderr reported, so the user will probably notice an
 *     error as long as we print one.  I've endeavored to do
 *     just that, for any error condition.
 *
 *     CVS should just break apart the files as separate
 *     arguments instead of passing the containing directory
 *     and whole list of files as a single argument, which
 *     forces us to jump through hoops to accomodate spaces
 *     in filenames and figure out where the directory ends
 *     and files begin.  We could get over argument stack
 *     space limitations by just using ARG_MAX; I don't know
 *     why they don't do this, it's a dumb way to pass
 *     arguments, as a single string.  ARG_MAX should be
 *     used anyways because the arguments are put on the
 *     stack either way.
 *
 * AUTHOR:
 *
 *     Scott Mcdermott, 2004
 */

#define _GNU_SOURCE

#include <sys/acl.h>

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

#include "util.h"
#include "acl.h"

^L

#define FILE_SEPARATOR                  " ,,,,,"
#define NEWDIR_SEPARATOR                " - New directory"
#define VERSION_DELETED                 "NONE"

^L

static  char *  get_cvsroot_or_exit     (void);
static  char *  astrtokstr              (char *s, const char *delim);
static  int     goto_repodir_and_open   (char *dir);
static  void    consume_stdin           (void);

^L

int
main (int argc,
      char **argv)
{
        unsigned arglen;
        int dirfd, parentfd;
        char *args, *dir, *tok;
        acl_t acl;

        /* avoid broken pipe, we don't use stdin */
        consume_stdin();

        if (--argc != 1) {
                fprintf(stderr, "%s: bad arg count\n", progname);
                exit(EXIT_FAILURE);
        }
        args = strdup(*++argv);
        arglen = strlen(args);

        /* were we given any files? */
        dir = astrtokstr(args, FILE_SEPARATOR);
        if (strlen(dir) == arglen) {

                /* no; we should be a new directory then */
                char *sepbegin = args + arglen - strlen(NEWDIR_SEPARATOR);
                if (!strcmp(sepbegin, NEWDIR_SEPARATOR)) {

                        /* indeed we are; set our ACL from
                         * the parent dir */
                        *sepbegin = '\0';
                        dirfd = goto_repodir_and_open(args);
                        parentfd = xopen("..", O_RDONLY);
                        acl = xacl_get_fd(parentfd);
                        xacl_set_fd(dirfd, acl);

                        /* new directories are given by
                         * themselves, and singly */
                        exit(EXIT_SUCCESS);
                } else {

                        /* it's not a new dir either */
                        fprintf(stderr, "%s: bad loginfo format\n", progname);
                        exit(EXIT_FAILURE);
                }
        }

        /* files are given; go to their base directory */
        dirfd = goto_repodir_and_open(dir);

        /* iterate through each file (we will be invoked
         * exactly once total with all the new or changed files
         * for any given directory */
        while ((tok = astrtokstr(NULL, FILE_SEPARATOR))) {

                char *newver;

                /* the text following the last comma is the
                 * new version; we use this to detect a
                 * deleted file, which obviously does not
                 * need permissions set on it! */
                newver = strrchr(tok, ',');
                *newver++ = '\0'; /* now tok is raw filename */
# if 0
                if (!strcmp(newver, VERSION_DELETED))
                        continue;
#endif
                set_repoacl_from_parent(tok, dirfd);
                free(tok);
        }

        /* if there are additional files in other
         * directories, we will be invoked again */
        exit(EXIT_SUCCESS);
}

^L

/*
 * Like strtok(), but uses a fixed string as the delimiter,
 * instead of a set of arbitrary delimiting characters, so
 * it's basically a strtok() implemented with strstr()
 * instead of strspn() and strpbrk().  Because we use
 * strstr() we have to exactly match the delimiter, so we
 * take care not to modify the source string, as is usual
 * with strtok().  To accomplish this we return the token in
 * a newly allocated string, for which the caller assumes
 * responsibility.
 */
static char *
astrtokstr (char *s, const char *delim)
{
        static char *olds;
        char *tok, *buf;
        size_t len;

        if (!s)
                s = olds;
        if (s == strstr(s, delim))
                s += strlen(delim);
        if (*s == '\0') {
                olds = s;
                return NULL;
        }
        tok = s;
        if (!(s = strstr(tok, delim)))
                /* last token */
                olds = rawmemchr(tok, '\0');
        else
                olds = s;
        len = olds - tok;
        buf = malloc(len + 1);
        memcpy(buf, tok, len);
        *(buf + len) = '\0';

        return buf;
}

static char *
get_cvsroot_or_exit (void)
{
        char *val;

        if (!(val = getenv(ENV_CVSROOT))) {
                fprintf(stderr, "%s: getenv: %s: %s\n",
                        progname, ENV_CVSROOT, strerror(errno));
                exit(EXIT_FAILURE);
        }

        return val;
}

static int
goto_repodir_and_open (char *dir)
{
        char *dirname;
        int dirfd;

        asprintf(&dirname, "%s/%s", get_cvsroot_or_exit(), dir);
        xchdir(dirname);
        dirfd = xopen(".", O_RDONLY);

        return dirfd;
}

static void
consume_stdin (void)
{
        int ret;
        char buf[BUFSIZ];

        while ((ret = read(STDIN_FILENO, buf, BUFSIZ))) {
                if (ret == -1) {
                        fprintf(stderr, "%s: read: %s\n",
                                progname, strerror(errno));
                        exit(EXIT_FAILURE);
                }
        }
}