Mini Shell

Direktori : /lib/fixperms/
Upload File :
Current File : //lib/fixperms/fixperms_base.py

"""Common fixperms classes"""
import os
from shlex import quote
from pathlib import Path
from typing import Union
from stat import S_IMODE, S_ISREG, S_ISDIR, S_ISLNK
import re
from fixperms_cli import Args
from fixperms_ids import IDCache


class PermMap:
    """Base class for fixperms"""

    def __init__(
        self,
        ids: IDCache,
        args: Args,
        user: str,
        all_docroots: list[str],
        docroot_chmod: int,
        docroot_chown: tuple[str, str],
    ):
        self.args = args
        self.skip = self.args.skip.copy()
        self.all_docroots = all_docroots
        self.log = args.logger
        self.hard_links = HardLinkTracker(self)
        self.ids = ids
        self.user = user
        pwuser = self.ids.getpwnam(user)
        self.uid = pwuser.pw_uid
        self.gid = pwuser.pw_gid
        doc_uid = ids.getpwnam(docroot_chown[0]).pw_uid
        doc_gid = ids.getgrnam(docroot_chown[1]).gr_gid
        self.docroot_perms = Rule('', (None, docroot_chmod), (doc_uid, doc_gid))
        self.homedir = os.path.realpath(pwuser.pw_dir)
        if not re.match(r'\/home\d*\/', self.homedir):
            raise ValueError(f"{user}: unexpected homedir: {self.homedir!r}")
        self.home_re = re.escape(pwuser.pw_dir)
        self.perm_map: list['Rule'] = []

    def add_rule(
        self,
        regex: str,
        modes: tuple[Union[int, None], Union[int, None]],
        chown: tuple[int, int],
    ) -> None:
        """Add a fixperms path rule. ^HOMEDIR is automatically added"""
        # no actual ^ becasue we use .match, not .search
        self.perm_map.append(Rule(f"{self.home_re}{regex}", modes, chown))

    def lchown(self, path: str, stat: os.stat_result, uid: int, gid: int):
        """Runs os.lchown"""
        if uid == gid == -1:
            return
        tgt_uid = stat.st_uid if uid == -1 else uid
        tgt_gid = stat.st_gid if gid == -1 else gid
        if (stat.st_uid, stat.st_gid) == (tgt_uid, tgt_gid):
            return
        if not self.args.noop:
            try:
                os.lchown(path, uid, gid)
            except OSError as exc:
                self.log.error(exc)
                return
        old_user = self.ids.uid_label(stat.st_uid)
        old_group = self.ids.gid_label(stat.st_gid)
        new_user = self.ids.uid_label(tgt_uid)
        new_group = self.ids.gid_label(tgt_gid)
        self.log.debug(
            'Changed ownership of %s from %s:%s to %s:%s',
            quote(path),
            old_user,
            old_group,
            new_user,
            new_group,
        )

    def lchmod(
        self,
        path: str,
        stat: os.stat_result,
        mode: Union[int, None],
    ):
        """Runs os.chmod if the path is not a symlink"""
        if mode is None:
            return
        orig = S_IMODE(stat.st_mode)
        if orig == mode:
            return
        if S_ISLNK(stat.st_mode):
            return  # Linux does not support follow_symlinks=False
        if not self.args.noop:
            try:
                os.chmod(path, mode)
            except OSError as exc:
                self.log.error(exc)
                return
        self.log.debug(
            'Changed mode of %s from %s to %s',
            quote(path),
            oct(orig)[2:],
            oct(mode)[2:],
        )

    def walk(self, path: str, ignore_skips: bool = False):
        """os.walk/os.lstat to yield a path and all of its contents"""
        for entry in self._walk(path, ignore_skips):
            try:
                stat = os.lstat(entry)
            except OSError as exc:
                self.log.error(exc)
                continue
            yield stat, entry

    def _walk(self, top_dir: str, ignore_skips: bool = False):
        if not ignore_skips and self.should_skip(top_dir):
            return
        yield top_dir
        if not os.path.isdir(top_dir):
            return
        for dirpath, dirnames, filenames in os.walk(top_dir):
            for filename in filenames:
                path = os.path.join(dirpath, filename)
                if ignore_skips or not self.should_skip(path):
                    yield path
            skip_dirs = []
            for dirname in dirnames:
                path = os.path.join(dirpath, dirname)
                if not ignore_skips and self.should_skip(path):
                    skip_dirs.append(path)
                else:
                    yield path
            if skip_dirs:
                # editing dirnames[:] in-place causes os.walk to not traverse it
                dirnames[:] = [x for x in dirnames if x not in skip_dirs]

    def run(self) -> None:
        """To be called from fixperms_main.py - processes this user"""
        self.fixperms()
        self.hard_links.handle()

    def fixperms(self) -> None:
        """Iterate over a user's files and chown/chmod as needed"""
        for stat, path in self.walk(self.homedir):
            try:
                self.check_path(stat, path)
            except OSError as exc:
                self.log.error(exc)

    def with_exec_bits(self, stat: os.stat_result, new_mode: Union[None, int]):
        """Get a new file mode including old mode's exec bits"""
        if new_mode is None:
            return None
        if self.args.preserve_exec:
            exec_bits = stat.st_mode & 0o111
            return new_mode | exec_bits
        return new_mode

    def check_path(self, stat: os.stat_result, path: str):
        """Chown and chmod files as necessary"""
        rule = self.find_rule(str(path))
        file_mode, dir_mode = rule.modes
        if S_ISREG(stat.st_mode):  # path is a regular file
            new_mode = self.with_exec_bits(stat, file_mode)
            if stat.st_nlink > 1:
                self.hard_links.add(path, stat, rule.chown, new_mode)
                return
        elif S_ISDIR(stat.st_mode):  # path is a directory
            new_mode = dir_mode
        elif S_ISLNK(stat.st_mode):  # path is a symlink
            new_mode = None
        else:  # path is socket/device/fifo/etc
            self.log.warning("Skipping unexpected path type at %s", path)
            return
        if new_mode is not None:
            self.lchmod(path, stat, new_mode)
        self.lchown(path, stat, *rule.chown)

    def find_rule(self, path: str) -> 'Rule':
        """Find the matching ``Rule`` for a given path"""
        assert isinstance(path, str)
        if path in self.all_docroots:
            return self.docroot_perms
        for rule in self.perm_map:
            if rule.regex.match(path):
                return rule
        raise ValueError(f"No matching rule for {path}")

    def should_skip(self, path: str):
        """Determine if a path should be skipped based on --skip args"""
        for skip in self.skip:
            if path == skip:
                return True
            if Path(path).is_relative_to(skip):
                return True
        return False


class HardLinkTracker:
    """Tracks and handles hard links discovered while walking through a
    user's files"""

    def __init__(self, perm_map: PermMap):
        self.perm_map = perm_map
        self.chowns: dict[int, tuple[int, int]] = {}
        self.stats: dict[int, os.stat_result] = {}
        self.modes: dict[int, int] = {}
        self.paths: dict[int, list[str]] = {}

    def add(
        self,
        path: str,
        stat: os.stat_result,
        chown: tuple[int, int],
        mode: Union[int, None],
    ):
        """Used to add a hard link found during the fixperms run which might
        be unsafe to operate on"""
        inum = stat.st_ino
        self.stats[inum] = stat  # will be the same for all ends of the link
        if inum in self.paths:
            self.paths[inum].append(path)
        else:
            self.paths[inum] = [path]
        if inum in self.chowns:
            uid, gid = chown
            prev_uid, prev_gid = self.chowns[inum]
            if uid == -1:
                uid = prev_uid
            if gid == -1:
                gid = prev_gid
            self.chowns[inum] = [uid, gid]
        else:
            self.chowns[inum] = chown
        if mode is not None:
            self.modes[inum] = mode

    def handle(self):
        """If self.hard_links was populated with any items, handle any that are
        safe, or log any that are not"""
        for inum, stat in self.stats.items():
            # for each distinct inode found with hard links...
            if stat.st_nlink == len(self.paths[inum]):
                # If we came across every end of the link in this run, then it's
                # safe to operate on. Chmod the first instance of it; the rest
                # will change with it.
                path = self.paths[inum][0]
                self.perm_map.lchown(path, stat, *self.chowns[inum])
                self.perm_map.lchmod(path, stat, self.modes.get(inum, None))
                continue
            # Otherwise these hard links can't be trusted.
            for path in self.paths[inum]:
                self.perm_map.log.error(
                    '%s is hardlinked and not owned by the user',
                    quote(path),
                )


class Rule:
    """Fixperms path rule"""

    def __init__(
        self,
        regex: str,
        modes: tuple[Union[int, None], Union[int, None]],
        chown: tuple[int, int],
    ):
        """Fixperms path rule

        Args:
            regex (str): regular expression
            file tuple[(int | None), (int | None)]: (file, dir) modes if matched
            chown tuple[int, int]: if a matching file/dir is found, chown to
                this UID/GID. Use -1 to make no change.
        """
        self.regex = re.compile(regex)
        assert isinstance(modes, tuple)
        assert isinstance(chown, tuple)
        self.modes = modes
        self.chown = chown

Zerion Mini Shell 1.0