#!/usr/pkg/bin/python3.13
'''queue_repair.py - qmail tools in Python.
Copyright (C) 2001 Charles Cazabon <pqt @ discworld.dyndns.org>

This program is free software; you can redistribute it and/or
modify it under the terms of version 2 of the GNU General Public License
as published by the Free Software Foundation.  A copy of this license should
be included in the file COPYING.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

'''

__version__ =   '0.9.0-symlink3-20180724'
__author__ =    'Charles Cazabon <pqt @ discworld.dyndns.org>'

import sys
import os
from stat import *
import string
import pwd
import grp
import getopt


#######################################
# globals
#######################################

confqmail    = '/var/qmail'
wd                  = None
testmode            = 1
checked_dir         = {}
checked_owner       = {}
checked_mode        = {}


#######################################
# data
#######################################

users = {
    'alias' : None,
    'qmaild' : None,
    'qmaill' : None,
    'qmailp' : None,
    'qmailq' : None,
    'qmailr' : None,
    'qmails' : None,
}

groups = {
    'qmail' : None,
    'nofiles' : None,
}

dirs = {
    # Directories to check; format is:
    #   key: pathname - all paths are relative to conf-qmail
    #   data: (user, group, mode, split, symlinkok)
    #       split is:  0 : no, 1 : yes, -1 : only with big-todo
    'queue' :           ('qmailq', 'qmail', 0o750, 0, True),
    'queue/bounce' :    ('qmails', 'qmail', 0o700, 0, False),
    'queue/info' :      ('qmails', 'qmail', 0o700, 1, False),
    'queue/intd' :      ('qmailq', 'qmail', 0o700, -1, False),
    'queue/local' :     ('qmails', 'qmail', 0o700, 1, False),
    'queue/lock' :      ('qmailq', 'qmail', 0o750, 0, False),
    'queue/mess' :      ('qmailq', 'qmail', 0o750, 1, False),
    'queue/pid' :       ('qmailq', 'qmail', 0o700, 0, False),
    'queue/remote' :    ('qmails', 'qmail', 0o700, 1, False),
    'queue/todo' :      ('qmailq', 'qmail', 0o750, -1, False),
}

nondirs = {
    # Files to check; format is:
    #   key: pathname - all paths are relative to conf-qmail
    #   data: (user, group, mode)
    'queue/lock/sendmutex' :    ('qmails', 'qmail', 0o600),
    'queue/lock/tcpto' :        ('qmailr', 'qmail', 0o644),
}


#######################################
# functions
#######################################

#######################################
def primes(min, max):
    '''primes(min, max)

    Return a list of primes between min and max inclusive.
    '''
    result = []
    primelist = [2]
    if min <= 2:
        result.append(2)
    i = 3
    while i <= max:
        for p in primelist:
            if (i % p == 0) or (p * p > i): break
        if (i % p != 0):
            primelist.append(i)
            if i >= min:
                result.append(i)
        i = i + 2
    return result

#######################################
def err(s, showhelp=0):
    '''err(s, showhelp=0)

    Write s + '\n' to stderr, optionally call show_help(), and exit.
    '''
    sys.stderr.write('%s\n' % s)
    if showhelp:
        show_help()
    if wd:
        os.chdir(wd)
    sys.exit(1)

#######################################
def msg(s):
    '''msg(s)

    Write s + '\n' to stdout.
    '''
    sys.stdout.write('%s\n' % s)

#######################################
def is_splitdir(is_split, bigtodo):
    '''is_splitdir(is_split, bigtodo)

    Return 0 if directory should not contain split subdirectories, 1 if it
    should.
    '''
    return (is_split == 1) or (is_split == -1 and bigtodo)

#######################################
def determine_users():
    '''determine_users()

    Look up UIDs and GIDs for all keys in globals users and groups which are
    not already set.
    '''
    global users, groups
    msg('finding qmail UIDs/GIDs...')
    us = list(users.keys())
    gs = list(groups.keys())
    for u in us:
        if users[u]:
            # Handle case of someone else determining UIDs for us
            msg('  %-7s preset as UID %i' % (u, users[u]))
            continue
        try:
            users[u] = pwd.getpwnam(u)[2]
        except KeyError:
            err('no uid for %s' % u)
        msg('  %-7s : UID %i' % (u, users[u]))
    for g in gs:
        if groups[g]:
            # Handle case of someone else determining GIDs for us
            msg('  %-7s preset as GID %i' % (g, groups[g]))
            continue
        try:
            groups[g] = grp.getgrnam(g)[2]
        except KeyError:
            err('no gid for %s' % g)
        msg('  %-7s : GID %i' % (g, groups[g]))

#######################################
def check_dir(path, user, group, mode, symlinkok):
    '''check_dir(path, user, group, mode, symlinkok)

    Verify path is an existing directory, that it owned by user:group, and
    that it has octal mode mode.  If testmode is set, create path if it
    doesn't exist.
    '''
    if path in checked_dir:
        return
    msg('  checking directory %s...' % path)
    if not os.path.exists(path):
        msg('  directory %s does not exist' % path)
        if not testmode:
            os.makedirs(path, mode)
    else:
        if not symlinkok and os.path.islink(path):
            msg('  %s is a symlink instead of directory' % path)
            if not testmode:
                os.unlink(path)
        if not os.path.isdir(path):
            msg('  %s is not a directory' % path)
            if not testmode:
                os.unlink(path)
    chown(path, user, group)
    chmod(path, mode)
    checked_dir[path] = None

#######################################
def chown(path, user, group):
    '''chown(path, user, group)

    Verify path is owned by user:group, and make it so if testmode is not set.
    '''
    if path in checked_owner:
        return
    uid = users[user]
    gid = groups[group]
    try:
        s = os.stat(path)
        if s[ST_UID] != uid or s[ST_GID] != gid:
            msg('  %s ownership %i:%i, should be %s:%s' % (path,
                s[ST_UID], s[ST_GID], user, group))
            if not testmode:
                os.chown(path, uid, gid)
                s = os.stat(path)
                msg('  fixed, %s ownership %i:%i' % (path, s[ST_UID], s[ST_GID]))
            else:
                msg('  testmode, not fixing')
    except OSError as o:
        err(o or '[no error message]')
    checked_owner[path] = None

#######################################
def chmod(path, mode):
    '''chmod(path, mode)

    Verify path has mode mode, and make it so if testmode is not set.
    '''
    if path in checked_mode:
        return
    try:
        s = os.stat(path)
        curmode = S_IMODE(s[ST_MODE])
        if curmode != mode:
            msg('  %s is mode %o, should be %o' % (path, curmode, mode))
            if not testmode:
                os.chmod(path, mode)
                s = os.stat(path)
                newmode = S_IMODE(s[ST_MODE])
                msg('  changed %s mode to %o' % (path, newmode))
            else:
                msg('  testmode, not fixing')
    except OSError as o:
        err(o or '[no error message]')
    checked_mode[path] = None

#######################################
def determine_split():
    '''determine_split()

    Return probable conf-split value of queue based on contents.
    '''
    splits = []
    msg('determining conf-split...')
    for (path, (user, group, mode, is_split, symlinkok)) in list(dirs.items()):
        if is_split != 1:
            continue
        highest = 0
        contents = os.listdir(path)
        for item in contents:
            p = os.path.join(path, item)
            if os.path.islink(p):
                msg('  found unexpected symlink %s' % p)
                continue
            if not os.path.isdir(p):
                msg('  found unexpected non-directory %s' % p)
                continue
            try:
                i = int(item)
            except ValueError:
                msg('  found unexpected directory %s' % p)
                continue
            if i > highest:
                highest = i
        splits.append(highest)
    split = splits[0]
    for i in splits[1:]:
        if i != split:
            err('  not all subdirectories split the same; use --split N to force')
    # First split directory is '0'
    split = split + 1
    msg('  conf-split appears to be %i' % split)
    return split

#######################################
def determine_bigtodo(split):
    '''determine_bigtodo(split)

    Return 1 if big-todo appears to be in use based on contents of queue,
    0 otherwise.
    '''
    splits = []
    bigtodo = 0
    msg('determining big-todo...')
    for i in range(split):
        p = os.path.join('queue/todo', str(i))
        if os.path.islink(p):
            msg('  found unexpected symlink %s' % p)
        elif os.path.isdir(p):
            splits.append(i)
        elif not os.path.exists(p):
            # big-todo probably not in use
            pass
        else:
            msg('  found unexpected direntry %s' % p)

    if splits == list(range(split)):
        # big-todo apparently in use
        bigtodo = 1
        msg('  big-todo found')
    elif splits:
        # big-todo in use, but doesn't match split
        err('  todo split != split; if using --split N, use --bigtodo to force')
    else:
        msg('  big-todo not found')

    return bigtodo

#######################################
def check_dirs(paths, split, bigtodo):
    '''check_dirs(paths, split, bigtodo)

    Verify ownership, mode, and contents of each queue directory in paths.
    '''
    msg('checking main queue directories...')
    _dirs = list(paths.keys())
    _dirs.sort()
    for path in _dirs:
        (user, group, mode, is_split, symlinkok) = paths[path]
        check_dir(path, user, group, mode, symlinkok)

    msg('checking split sub-directories...')
    for (path, (user, group, mode, is_split, symlinkok)) in list(paths.items()):
        if path in ('queue', 'queue/lock'):
            # Nothing in these directories to check at this point
            continue
        this_split = is_splitdir(is_split, bigtodo)
        if not this_split:
            splits = []
        else:
            splits = list(range(split))
            for i in splits:
                splitpath = os.path.join(path, str(i))
                check_dir(splitpath, user, group, mode, symlinkok)

        try:
            contents = os.listdir(path)
        except OSError:
            # Directory missing
            if testmode:
                continue
            err('bug -- directory %s missing, should exist by now' % path)

        for item in contents:
            p = os.path.join(path, item)
            if this_split:
                if (is_split == -1) and os.path.isfile(p):
                    # Found possible file in path while converting queue to
                    # big-todo
                    try:
                        i = int(item)
                        if not testmode:
                            # Move to '0' split subdirectory; will be
                            # fixed later by check_hash_and_ownership
                            new_p = os.path.join(path, '0', item)
                            msg('  moving %s to %s' % (p, new_p))
                            os.rename(p, new_p)
                    except ValueError:
                        # Not a message file
                        msg('  found unexpected file %s' % p)
                        continue
                # This directory should contain only split subdirectories
                if not os.path.isdir(p):
                    msg('  found unexpected direntry %s' % p)
                    continue
                try:
                    i = int(item)
                    if i not in splits:
                        msg('  found unexpected split subdirectory %s' % p)
                        if not testmode:
                            files = os.listdir(p)
                            for f in files:
                                # Move any files in this to-be-remove split subdir
                                # into the 0 splitdir.  Will be moved into the
                                # proper split subdir later by
                                # check_hash_and_ownership().
                                filep = os.path.join(p, f)
                                msg('  preserving file %s' % filep)
                                os.rename(filep, os.path.join(path, '0', f))
                            os.removedirs(p)
                except ValueError:
                    msg('  found unexpected direntry %s' % p)
            else:
                # This directory should contain only files
                if os.path.isdir(p):
                    msg('  found unexpected directory %s' % p)
                    try:
                        i = int(item)
                    except ValueError:
                        msg('  %s not a split subdirectory; ignoring' % p)
                        continue
                    msg('  %s is a split subdirectory; %s should not be split' % (p, path))
                    if not testmode:
                        savefiles = os.listdir(p)
                        if savefiles:
                            msg('  moving files from %s to %s' % (p, path))
                            for f in savefiles:
                                os.rename(os.path.join(p, f), os.path.join(path, f))
                        os.rmdir(p)
                elif not os.path.isfile(p):
                    msg('  found unexpected direntry %s; ignoring' % p)
                    continue
                else:
                    # Found file
                    pass

#######################################
def check_files(paths):
    '''check_files(paths)

    Verify ownership and mode of each queue file in paths.
    '''
    msg('checking files...')
    for (path, (user, group, mode)) in list(paths.items()):
        if os.path.exists(path):
            if not os.path.isfile(path):
                msg('  %s is not a file' % path)
                if not testmode:
                    os.unlink(path)
        else:
            msg('  file %s does not exist' % path)
        if not os.path.exists(path) and not testmode:
            open(path, 'w')
        chown(path, user, group)
        chmod(path, mode)

#######################################
def check_trigger():
    '''check_trigger()

    Verify ownership, mode, and inode type of trigger fifo.
    '''
    path = 'queue/lock/trigger'
    user = 'qmails'
    group = 'qmail'

    if not os.path.exists(path):
        msg('  %s missing' % path)
    else:
        if os.path.islink(path):
            msg('  %s is a symlink instead of fifo' % path)
            if not testmode:
                os.unlink(path)
        else:
            mode = os.stat(path)[ST_MODE]
            if not S_ISFIFO(mode):
                msg('  %s not a fifo' % path)
                if not testmode:
                    os.unlink(path)
    if not os.path.exists(path) and not testmode:
        os.mkfifo(path)
    chown(path, user, group)
    chmod(path, 0o622)

#######################################
def check_messages(path, split):
    '''check_messages(path, split)

    Return list of files found under path which are not named after their
    inode number.
    '''
    misnamed = []
    msg('checking queue/mess files...')
    for i in range(split):
        messdir = os.path.join(path, str(i))
        try:
            contents = os.listdir(messdir)
        except OSError:
            continue
        for f in contents:
            p = os.path.join(messdir, f)
            if os.path.islink(p):
                msg('  found unexpected symlink %s' % p)
                continue
            elif not os.path.isfile(p):
                msg('  found unexpected non-file %s' % p)
                continue
            try:
                filenum = int(f)
            except ValueError:
                msg('  found unexpected file %s' % p)
                continue

            s = os.stat(p)
            inode = s[ST_INO]
            if filenum == inode:
                continue
            # Found mess file not named after inode
            msg('  %s is inode %i' % (p, inode))
            # Will be fixed by fix_inode_names()
            misnamed.append((i, filenum, inode))
    return misnamed

#######################################
def fix_inode_names(paths, split, bigtodo, misnamed):
    '''fix_inode_names(paths, split, bigtodo, misnamed)

    For each path in paths, correct file names based on results of
    check_messages().  Correct split sub-directory location as well.
    '''
    msg('fixing misnamed messages...')
    for (path, (user, junk, junk, is_split, symlinkok)) in list(paths.items()):
        for (oldhash, oldno, newno) in misnamed:
            if not is_splitdir(is_split, bigtodo):
                old_p = os.path.join(path, str(oldno))
                new_p = os.path.join(path, str(newno))
            else:
                old_p = os.path.join(path, str(oldhash), str(oldno))
                new_p = os.path.join(path, str(newno % split), str(newno))
            if os.path.exists(old_p):
                if os.path.islink(old_p):
                    msg('  found unexpected symlink %s' % old_p)
                    continue
                if not os.path.isfile(old_p):
                    msg('  found unexpected direntry %s' % old_p)
                    continue
                msg('  %s should be %s' % (old_p, new_p))
                if not testmode:
                    os.rename(old_p, new_p)
                    msg('    fixed')

#######################################
def check_hash_and_ownership(paths, split, bigtodo):
    '''check_hash_and_ownership(paths, split, bigtodo)

    For each path in paths, correct file ownership, mode, and split subdirectory
    of all files found.
    '''
    msg('checking split locations...')
    for (path, (user, group, junk, is_split, symlinkok)) in list(paths.items()):
        if path in ('queue', 'queue/lock'):
            # Nothing in these directories to check at this point
            continue
        elif path in ('queue/mess', 'queue/todo'):
            mode = 0o644
        else:
            mode = 0o600
        this_split = is_splitdir(is_split, bigtodo)
        if this_split:
            splits = list(range(split))
        else:
            splits = ['']
        for splitval in splits:
            _dir = os.path.join(path, str(splitval))
            try:
                contents = os.listdir(_dir)
            except OSError:
                if not testmode:
                    err('bug -- directory %s missing, should exist by now' % _dir)
                continue
            for f in contents:
                old_p = os.path.join(_dir, f)
                try:
                    if not os.path.isfile(old_p):
                        raise ValueError
                    j = int(f)
                except ValueError:
                    msg('  found unexpected direntry %s; ignoring' % old_p)
                    continue
                # Check ownership and mode
                chown(old_p, user, group)
                chmod(old_p, mode)
                if not this_split:
                    continue
                # Check whether file is in correct split sub-directory
                hashv = j % split
                if hashv != splitval:
                    # message in wrong split dir
                    new_p = os.path.join(path, str(hashv), f)
                    msg('  %s should be %s' % (old_p, new_p))
                    if not testmode:
                        os.rename(old_p, new_p)
                        # Ensure ownership and mode
                        chown(new_p, user, group)
                        chmod(new_p, mode)
                        msg('    fixed')

#######################################
def get_current_messages(split):
    '''get_current_messages(split)

    Return list of all message files under queue/mess.
    '''
    messages = []
    msg('finding current messages...')
    for i in range(split):
        path = os.path.join('queue/mess', str(i))
        try:
            contents = os.listdir(path)
        except OSError:
            continue
        for item in contents:
            try:
                messages.append(int(item))
            except ValueError:
                if testmode:
                    pass
                else:
                    msg('  found unexpected direntry %s' % os.path.join(path, item))
    messages.sort()
    msg('  found %i messages' % len(messages))
    return messages

#######################################
def check_queue(qmaildir=confqmail, test=1, force_split=None, force_bigtodo=None, force_create=0, mathishard=0):
    '''check_queue(qmaildir=confqmail, test=1, force_split=None, force_bigtodo=None, force_create=0, mathishard=0)

    Verify (and correct if test is not set) queue structure rooted at
    qmaildir/queue.
    Determine conf-split automatically if force_split is not set.
    Determine if big-todo is in use automatically if force_bigtodo is not set.
    '''
    global wd
    global testmode
    testmode = test
    split = None

    wd = os.getcwd()
    try:
        os.chdir(qmaildir)
    except Exception:
        err('failed to chdir to %s' % qmaildir)

    if testmode:
        msg('running in test-only mode')
    else:
        msg('running in repair mode')

    determine_users()

    if not force_split:
        try:
            split = determine_split()
        except OSError:
            msg('basic queue directories not found at %s' % qmaildir)

    if not split:
        if not force_create:
                err('  use --create to force creation of queue at %s' % qmaildir)
        # --create implies --repair
        testmode = 0
        if not force_split:
                err('if creating a new queue, you must supply a conf-split value with --split')
        split = int(force_split)
        if split < 1:
            err('split must be >= 1')
        if not force_bigtodo:
                err('if creating a new queue, you must supply either --bigtodo or --no-bigtodo')
        msg('using forced conf-split of %i' % split)
        msg('creating new queue at %s' % qmaildir)

    l = int(split * 0.8)
    h = int(split * 1.2)
    suggested_splits = primes(l, h)
    if not split in suggested_splits:
        msg('split should be prime, not %i:  suggestions %s' % (split, suggested_splits))
        if not mathishard and not testmode:
            err('  use --i-want-a-broken-conf-split to force non-prime split')

    if force_bigtodo == 1:
        bigtodo = 1
        msg('using forced big-todo')
    elif force_bigtodo == -1:
        bigtodo = 0
        msg('using forced non-big-todo')
    else:
        bigtodo = determine_bigtodo(split)

    check_dirs(dirs, split, bigtodo)
    check_files(nondirs)
    check_trigger()

    misnamed = check_messages('queue/mess', split)

    # Handle misnamed files in directories
    if misnamed:
        fix_inode_names(dirs, split, bigtodo, misnamed)

    # Handle mis-hashed files and bad owner/group/mode
    check_hash_and_ownership(dirs, split, bigtodo)

#######################################
def show_help():
    '''show_help()

    Display usage information.
    '''
    msg('\n'
        'Usage:  queue_repair.py [options] [conf-qmail]\n')
    msg('Options:\n'
        '  conf-qmail                    (default:  %s)' % confqmail)
    msg(
        '  -t   or --test                Test only; do not modify the filesystem\n'
        '  -r   or --repair              Repair errors found        (default: test)\n'
        '  -b   or --bigtodo             Force use of big-todo      (default: auto)\n'
        '  -n   or --no-bigtodo          Force non-use of big-todo  (default: auto)\n'
        '  -s N or --split N             Force conf-split of N      (default: auto)\n'
        '  -c   or --create              Force creation of queue    (default: no)\n'
        '  --i-want-a-broken-conf-split  Force non-prime conf-split (default: no)\n'
        '  -h   or --help                This text\n'
        )

#######################################
def main():
    '''main()

    Parse options and call check_queue().
    '''
    msg('queue_repair.py v. %s\n'
        'Copyright (C) 2001 %s' % (__version__, __author__))
    msg('Licensed under the GNU General Public License version 2\n')

    optionlist = 's:bnrthc'
    longoptionlist = ('split=', 'bigtodo', 'no-bigtodo', 'repair', 'test',
        'i-want-a-broken-conf-split', 'help', 'create')
    force_split = None
    force_bigtodo = None
    test = 1
    qmaildir = confqmail
    mathishard = 0
    create = 0

    try:
        options, args = getopt.getopt(sys.argv[1:], optionlist, longoptionlist)

        for (option, value) in options:
            if option in ('-s', '--split'):
                try:
                    force_split = int(value)
                    if force_split < 1:
                        raise ValueError
                except ValueError:
                    raise getopt.error('split value must be a positive integer (%s)' % value)
            elif option in ('-n', '--no-bigtodo'):
                force_bigtodo = -1
            elif option in ('-b', '--bigtodo'):
                force_bigtodo = 1
            elif option in ('-r', '--repair'):
                test = 0
            elif option in ('-t', '--test'):
                test = 1
            elif option == '--i-want-a-broken-conf-split':
                mathishard = 1
            elif option in ('-h', '--help'):
                show_help()
                sys.exit(0)
            elif option in ('-c', '--create'):
                create = 1
        if args:
            if len(args) > 1:
                raise getopt.error('conf-qmail must be a single argument (%s)' % string.join(args))
            qmaildir = args[0]

    except getopt.error as o:
        err('Error:  %s' % o, showhelp=1)

    check_queue(qmaildir, test, force_split, force_bigtodo, create, mathishard)

#######################################
if __name__ == '__main__':
    main()
