#!/usr/bin/env python # Copyright 1999-2021 Gentoo Authors # Distributed under the terms of the GNU General Public License v2 # # dispatch-conf -- Integrate modified configs, post-emerge # # Jeremy Wohl (http://igmus.org) # # TODO # dialog menus # import atexit import errno import re import shlex import subprocess import sys import termios import tty from stat import ST_GID, ST_MODE, ST_UID from random import random try: import curses except ImportError: curses = None from os import path as osp if osp.isfile( osp.join(osp.dirname(osp.dirname(osp.realpath(__file__))), ".portage_not_installed") ): sys.path.insert( 0, osp.join(osp.dirname(osp.dirname(osp.realpath(__file__))), "lib") ) import portage portage._internal_caller = True from portage import os, shutil from portage import _encodings, _unicode_decode from portage.dispatch_conf import ( diffstatusoutput, diff_mixed_wrapper, perform_conf_update_hooks, perform_conf_update_session_hooks, ) from portage.process import find_binary, spawn from portage.util import writemsg, writemsg_stdout DIFF_CONTENTS = "diff -Nu '%s' '%s'" # We need a secure scratch dir and python does silly verbose errors on the use of tempnam oldmask = os.umask(0o077) SCRATCH_DIR = None while SCRATCH_DIR is None: try: mydir = "/tmp/dispatch-conf." for x in range(0, 8): if int(random() * 3) == 0: mydir += chr(int(65 + random() * 26.0)) elif int(random() * 2) == 0: mydir += chr(int(97 + random() * 26.0)) else: mydir += chr(int(48 + random() * 10.0)) if os.path.exists(mydir): continue os.mkdir(mydir) SCRATCH_DIR = mydir except OSError as e: if e.errno != 17: raise os.umask(oldmask) # Ensure the scratch dir is deleted def cleanup(mydir=SCRATCH_DIR): shutil.rmtree(mydir) atexit.register(cleanup) MANDATORY_OPTS = ["archive-dir", "diff", "replace-cvs", "replace-wscomments", "merge"] def cmd_var_is_valid(cmd): """ Return true if the first whitespace-separated token contained in cmd is an executable file, false otherwise. """ cmd = shlex.split(cmd) if not cmd: return False if os.path.isabs(cmd[0]): return os.access(cmd[0], os.EX_OK) return find_binary(cmd[0]) is not None diff = diff_mixed_wrapper(diffstatusoutput, DIFF_CONTENTS) class dispatch: options = {} def grind(self, config_paths): confs = [] count = 0 perform_conf_update_session_hooks("pre-session") config_root = portage.settings["EROOT"] self.options = portage.dispatch_conf.read_config(MANDATORY_OPTS) if "log-file" in self.options: if os.path.isfile(self.options["log-file"]): shutil.copy(self.options["log-file"], self.options["log-file"] + ".old") if os.path.isfile(self.options["log-file"]) or not os.path.exists( self.options["log-file"] ): old_umask = os.umask(0o077) open(self.options["log-file"], "w").close() # Truncate it os.umask(old_umask) pager = self.options.get("pager") if pager is None or not cmd_var_is_valid(pager): pager = os.environ.get("PAGER") if pager is None or not cmd_var_is_valid(pager): pager = "cat" pager_basename = os.path.basename(shlex.split(pager)[0]) if pager_basename == "less": less_opts = self.options.get("less-opts") if less_opts is not None and less_opts.strip(): pager += " " + less_opts if pager_basename == "cat": pager = "" else: pager = " | " + pager # # Build list of extant configs # for path in config_paths: path = portage.normalize_path( os.path.join(config_root, path.lstrip(os.sep)) ) # Protect files that don't exist (bug #523684). If the # parent directory doesn't exist, we can safely skip it. if not os.path.isdir(os.path.dirname(path)): continue basename = "*" find_opts = ["-name", ".*", "-type", "d", "-prune", "-o"] if not os.path.isdir(path): path, basename = os.path.split(path) find_opts = ["-maxdepth", "1"] if "case-insensitive-fs" in portage.settings.features: find_opts += ["-iname"] else: find_opts += ["-name"] find_opts += [ f"._cfg????_{basename}", "!", "-name", ".*~", "!", "-iname", ".*.bak", "-print", ] try: # Find existing configs path_list = _unicode_decode( subprocess.check_output(["find", path] + find_opts), errors="strict", ).splitlines() except subprocess.CalledProcessError: pass else: confs.extend(self.massage(path_list)) if self.options["use-rcs"] == "yes": for rcs_util in ("rcs", "ci", "co", "rcsmerge"): if not find_binary(rcs_util): print( 'dispatch-conf: Error finding all RCS utils and " + \ "use-rcs=yes in config; fatal', file=sys.stderr, ) return False # config file freezing support frozen_files = set(self.options.get("frozen-files", "").split()) auto_zapped = [] protect_obj = portage.util.ConfigProtect( config_root, config_paths, portage.settings.get("CONFIG_PROTECT_MASK", "").split(), case_insensitive=("case-insensitive-fs" in portage.settings.features), ) # # Remove new configs identical to current # and # Auto-replace configs a) whose differences are simply CVS interpolations, # or b) whose differences are simply ws or comments, # or c) in paths now unprotected by CONFIG_PROTECT_MASK, # def f(conf): mrgconf = re.sub(r"\._cfg", "._mrg", conf["new"]) archive = os.path.join( self.options["archive-dir"], conf["current"].lstrip("/") ) if self.options["use-rcs"] == "yes": mrgfail = portage.dispatch_conf.rcs_archive( archive, conf["current"], conf["new"], mrgconf ) else: mrgfail = portage.dispatch_conf.file_archive( archive, conf["current"], conf["new"], mrgconf ) if os.path.lexists(archive + ".dist"): unmodified = len(diff(conf["current"], archive + ".dist")[1]) == 0 else: unmodified = 0 if os.path.exists(mrgconf): if mrgfail or len(diff(conf["new"], mrgconf)[1]) == 0: os.unlink(mrgconf) newconf = conf["new"] else: newconf = mrgconf else: newconf = conf["new"] if ( newconf == mrgconf and self.options.get("ignore-previously-merged") != "yes" and os.path.lexists(archive + ".dist") and len(diff(archive + ".dist", conf["new"])[1]) == 0 ): # The current update is identical to the archived .dist # version that has previously been merged. os.unlink(mrgconf) newconf = conf["new"] mystatus, myoutput = diff(conf["current"], newconf) myoutput_len = len(myoutput) same_file = 0 == myoutput_len if mystatus >> 8 == 2: # Binary files differ same_cvs = False same_wsc = False else: # Extract all the normal diff lines (ignore the headers). mylines = re.findall("^[+-][^\n+-].*$", myoutput, re.MULTILINE) # Filter out all the cvs headers cvs_header = re.compile("# [$]Header:") cvs_lines = list(filter(cvs_header.search, mylines)) same_cvs = len(mylines) == len(cvs_lines) # Filter out comments and whitespace-only changes. # Note: be nice to also ignore lines that only differ in whitespace... wsc_lines = [] for x in [r"^[-+]\s*#", r"^[-+]\s*$"]: wsc_lines += list(filter(re.compile(x).match, mylines)) same_wsc = len(mylines) == len(wsc_lines) # Do options permit? same_cvs = same_cvs and self.options["replace-cvs"] == "yes" same_wsc = same_wsc and self.options["replace-wscomments"] == "yes" unmodified = unmodified and self.options["replace-unmodified"] == "yes" if same_file: os.unlink(conf["new"]) self.post_process(conf["current"]) if os.path.exists(mrgconf): os.unlink(mrgconf) return False elif conf["current"] in frozen_files: """Frozen files are automatically zapped. The new config has already been archived with a .new suffix. When zapped, it is left with the .new suffix (post_process is skipped), since it hasn't been merged into the current config.""" auto_zapped.append(conf["current"]) os.unlink(conf["new"]) try: os.unlink(mrgconf) except OSError: pass return False elif ( unmodified or same_cvs or same_wsc or not protect_obj.isprotected(conf["current"]) ): self.replace(newconf, conf["current"]) self.post_process(conf["current"]) if newconf == mrgconf: os.unlink(conf["new"]) elif os.path.exists(mrgconf): os.unlink(mrgconf) return False else: return True confs = [x for x in confs if f(x)] # # Interactively process remaining # valid_input = "qhtnmlezu" def diff_pager(file1, file2): cmd = self.options["diff"] % (file1, file2) cmd += pager spawn_shell(cmd) diff_pager = diff_mixed_wrapper(diff_pager) for conf in confs: count = count + 1 newconf = conf["new"] mrgconf = re.sub(r"\._cfg", "._mrg", newconf) if os.path.exists(mrgconf): newconf = mrgconf show_new_diff = 0 while 1: clear_screen() if show_new_diff: diff_pager(conf["new"], mrgconf) show_new_diff = 0 else: diff_pager(conf["current"], newconf) print() writemsg_stdout( f">> ({count} of {len(confs)}) -- {conf['current']}\n", noiselevel=-1, ) print( ">> q quit, h help, n next, e edit-new, z zap-new, u use-new\n m merge, t toggle-merge, l look-merge: ", end=" ", ) # In some cases getch() will return some spurious characters # that do not represent valid input. If we don't validate the # input then the spurious characters can cause us to jump # back into the above "diff" command immediatly after the user # has exited it (which can be quite confusing and gives an # "out of control" feeling). while True: c = getch() if c in valid_input: sys.stdout.write("\n") sys.stdout.flush() break if c == "q": perform_conf_update_session_hooks("post-session") sys.exit(0) if c == "h": self.do_help() continue elif c == "t": if newconf == mrgconf: newconf = conf["new"] elif os.path.exists(mrgconf): newconf = mrgconf continue elif c == "n": break elif c == "m": merged = SCRATCH_DIR + "/" + os.path.basename(conf["current"]) print() ret = os.system( self.options["merge"] % (merged, conf["current"], newconf) ) ret = os.WEXITSTATUS(ret) if ret < 2: ret = 0 if ret: print("Failure running 'merge' command") continue shutil.copyfile(merged, mrgconf) os.remove(merged) mystat = os.lstat(conf["new"]) os.chmod(mrgconf, mystat[ST_MODE]) os.chown(mrgconf, mystat[ST_UID], mystat[ST_GID]) if "selinux" in portage.settings.features: self.copy_selinux_label(conf["current"], mrgconf) newconf = mrgconf continue elif c == "l": show_new_diff = 1 continue elif c == "e": if "EDITOR" not in os.environ: os.environ["EDITOR"] = "nano" os.system(os.environ["EDITOR"] + " " + newconf) continue elif c == "z": os.unlink(conf["new"]) if os.path.exists(mrgconf): os.unlink(mrgconf) break elif c == "u": self.replace(newconf, conf["current"]) self.post_process(conf["current"]) if newconf == mrgconf: os.unlink(conf["new"]) elif os.path.exists(mrgconf): os.unlink(mrgconf) break else: raise AssertionError(f"Invalid Input: {c}") if auto_zapped: print() print(" One or more updates are frozen and have been automatically zapped:") print() for frozen in auto_zapped: writemsg_stdout(f" * '{frozen}'\n", noiselevel=-1) print() perform_conf_update_session_hooks("post-session") def copy_selinux_label(self, curconf, newconf): """Copy the SELinux security label from the current config file to the new/merged config file.""" try: label = os.getxattr(curconf, "security.selinux") except OSError as e: if e.errno == errno.ENOTSUP: # Filesystem does not support xattrs return writemsg( f"dispatch-conf: Failed getting SELinux label on {curconf}; ignoring...\n", noiselevel=-1, ) return if label: try: os.setxattr(newconf, "security.selinux", label) except OSError: writemsg( f"dispatch-conf: Failed setting SELinux label on {newconf}; ignoring...\n", noiselevel=-1, ) def replace(self, newconf, curconf): """Replace current config with the new/merged version. Also logs the diff of what changed into the configured log file.""" if "log-file" in self.options: status, output = diff(curconf, newconf) with open( self.options["log-file"], mode="a", encoding=_encodings["stdio"] ) as f: f.write(output + "\n") perform_conf_update_hooks("pre-update", curconf) try: os.rename(newconf, curconf) except OSError as why: writemsg( f"dispatch-conf: Error renaming {newconf} to {curconf}: {str(why)}; fatal\n", noiselevel=-1, ) return perform_conf_update_hooks("post-update", curconf) def post_process(self, curconf): archive = os.path.join(self.options["archive-dir"], curconf.lstrip("/")) if self.options["use-rcs"] == "yes": portage.dispatch_conf.rcs_archive_post_process(archive) else: portage.dispatch_conf.file_archive_post_process(archive) def massage(self, newconfigs): """Sort, rstrip, remove old versions, break into triad hash. Triad is dictionary of current (/etc/make.conf), new (/etc/._cfg0003_make.conf) and dir (/etc). We keep ._cfg0002_conf over ._cfg0001_conf and ._cfg0000_conf. """ h = {} configs = [] newconfigs.sort() for nconf in newconfigs: # Use strict mode here, because we want to know if it fails, # and portage only merges files with valid UTF-8 encoding. nconf = _unicode_decode(nconf, errors="strict").rstrip() conf = re.sub(r"\._cfg\d+_", "", nconf) dirname = os.path.dirname(nconf) conf_map = { "current": conf, "dir": dirname, "new": nconf, } if conf in h: mrgconf = re.sub(r"\._cfg", "._mrg", h[conf]["new"]) if os.path.exists(mrgconf): os.unlink(mrgconf) os.unlink(h[conf]["new"]) h[conf].update(conf_map) else: h[conf] = conf_map configs.append(conf_map) return configs def do_help(self): print() print() print(" u -- update current config with new config and continue") print(" z -- zap (delete) new config and continue") print(" n -- skip to next config, leave all intact") print(" e -- edit new config") print(" m -- interactively merge current and new configs") print(" l -- look at diff between pre-merged and merged configs") print(" t -- toggle new config between merged and pre-merged state") print(" h -- this screen") print(" q -- quit") print() print("press any key to return to diff...", end=" ") getch() def getch(): # from ASPN - Danny Yoo # fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(sys.stdin.fileno()) ch = sys.stdin.read(1) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch def clear_screen(): if curses is not None: try: curses.setupterm() sys.stdout.write(_unicode_decode(curses.tigetstr("clear"))) sys.stdout.flush() return except curses.error: pass os.system("clear 2>/dev/null") def spawn_shell(cmd): sys.__stdout__.flush() sys.__stderr__.flush() spawn( ["sh", "-c", cmd], env=os.environ, fd_pipes={ 0: portage._get_stdin().fileno(), 1: sys.__stdout__.fileno(), 2: sys.__stderr__.fileno(), }, ) def usage(argv): print("dispatch-conf: sane configuration file update\n") print("Usage: dispatch-conf [config dirs]\n") print("See the dispatch-conf(1) man page for more details") sys.exit(os.EX_OK) for x in sys.argv: if x in ("-h", "--help"): usage(sys.argv) elif x in ("--version",): print("Portage", portage.VERSION) sys.exit(os.EX_OK) # run d = dispatch() if len(sys.argv) > 1: # for testing d.grind(sys.argv[1:]) else: d.grind(portage.settings.get("CONFIG_PROTECT", "").split())