check-implicit-pointer-usage: safe cwd bug 469338
[portage.git] / bin / dispatch-conf
1 #!/usr/bin/python -O
2 # Copyright 1999-2013 Gentoo Foundation
3 # Distributed under the terms of the GNU General Public License v2
4
5 #
6 # dispatch-conf -- Integrate modified configs, post-emerge
7 #
8 #  Jeremy Wohl (http://igmus.org)
9 #
10 # TODO
11 #  dialog menus
12 #
13
14 from __future__ import print_function
15
16 from stat import ST_GID, ST_MODE, ST_UID
17 from random import random
18 import atexit, re, shutil, stat, sys
19 from os import path as osp
20 pym_path = osp.join(osp.dirname(osp.dirname(osp.realpath(__file__))), "pym")
21 sys.path.insert(0, pym_path)
22 import portage
23 portage._internal_caller = True
24 from portage import os
25 from portage import dispatch_conf
26 from portage import _unicode_decode
27 from portage.dispatch_conf import diffstatusoutput
28 from portage.process import find_binary
29
30 FIND_EXTANT_CONFIGS  = "find '%s' %s -name '._cfg????_%s' ! -name '.*~' ! -iname '.*.bak' -print"
31 DIFF_CONTENTS        = "diff -Nu '%s' '%s'"
32
33 # We need a secure scratch dir and python does silly verbose errors on the use of tempnam
34 oldmask = os.umask(0o077)
35 SCRATCH_DIR = None
36 while SCRATCH_DIR is None:
37     try:
38         mydir = "/tmp/dispatch-conf."
39         for x in range(0,8):
40             if int(random() * 3) == 0:
41                 mydir += chr(int(65+random()*26.0))
42             elif int(random() * 2) == 0:
43                 mydir += chr(int(97+random()*26.0))
44             else:
45                 mydir += chr(int(48+random()*10.0))
46         if os.path.exists(mydir):
47             continue
48         os.mkdir(mydir)
49         SCRATCH_DIR = mydir
50     except OSError as e:
51         if e.errno != 17:
52             raise
53 os.umask(oldmask)
54
55 # Ensure the scratch dir is deleted
56 def cleanup(mydir=SCRATCH_DIR):
57     shutil.rmtree(mydir)
58 atexit.register(cleanup)
59
60 MANDATORY_OPTS = [ 'archive-dir', 'diff', 'replace-cvs', 'replace-wscomments', 'merge' ]
61
62 def cmd_var_is_valid(cmd):
63     """
64     Return true if the first whitespace-separated token contained
65     in cmd is an executable file, false otherwise.
66     """
67     cmd = portage.util.shlex_split(cmd)
68     if not cmd:
69         return False
70
71     if os.path.isabs(cmd[0]):
72         return os.access(cmd[0], os.EX_OK)
73
74     return find_binary(cmd[0]) is not None
75
76 class dispatch:
77     options = {}
78
79     def grind (self, config_paths):
80         confs = []
81         count = 0
82
83         config_root = portage.const.EPREFIX or os.sep
84         self.options = portage.dispatch_conf.read_config(MANDATORY_OPTS)
85
86         if "log-file" in self.options:
87             if os.path.isfile(self.options["log-file"]):
88                 shutil.copy(self.options["log-file"], self.options["log-file"] + '.old')
89             if os.path.isfile(self.options["log-file"]) \
90                or not os.path.exists(self.options["log-file"]):
91                 open(self.options["log-file"], 'w').close() # Truncate it
92                 os.chmod(self.options["log-file"], 0o600)
93         else:
94             self.options["log-file"] = "/dev/null"
95
96         pager = self.options.get("pager")
97         if pager is None or not cmd_var_is_valid(pager):
98             pager = os.environ.get("PAGER")
99             if pager is None or not cmd_var_is_valid(pager):
100                 pager = "cat"
101
102         pager_basename = os.path.basename(portage.util.shlex_split(pager)[0])
103         if pager_basename == "less":
104             less_opts = self.options.get("less-opts")
105             if less_opts is not None and less_opts.strip():
106                 pager += " " + less_opts
107
108         if pager_basename == "cat":
109             pager = ""
110         else:
111             pager = " | " + pager
112
113         #
114         # Build list of extant configs
115         #
116
117         for path in config_paths:
118             path = portage.normalize_path(
119                  os.path.join(config_root, path.lstrip(os.sep)))
120             try:
121                 mymode = os.stat(path).st_mode
122             except OSError:
123                 continue
124             basename = "*"
125             find_opts = "-name '.*' -type d -prune -o"
126             if not stat.S_ISDIR(mymode):
127                 path, basename = os.path.split(path)
128                 find_opts = "-maxdepth 1"
129
130             with os.popen(FIND_EXTANT_CONFIGS %
131                 (path, find_opts, basename)) as proc:
132                 confs += self.massage(proc.readlines())
133
134         if self.options['use-rcs'] == 'yes':
135             for rcs_util in ("rcs", "ci", "co", "rcsmerge"):
136                 if not find_binary(rcs_util):
137                     print('dispatch-conf: Error finding all RCS utils and " + \
138                         "use-rcs=yes in config; fatal', file=sys.stderr)
139                     return False
140
141
142         # config file freezing support
143         frozen_files = set(self.options.get("frozen-files", "").split())
144         auto_zapped = []
145         protect_obj = portage.util.ConfigProtect(
146             config_root, config_paths,
147             portage.util.shlex_split(
148             portage.settings.get('CONFIG_PROTECT_MASK', '')))
149
150         def diff(file1, file2):
151             return diffstatusoutput(DIFF_CONTENTS, file1, file2)
152
153         #
154         # Remove new configs identical to current
155         #                  and
156         # Auto-replace configs a) whose differences are simply CVS interpolations,
157         #                  or  b) whose differences are simply ws or comments,
158         #                  or  c) in paths now unprotected by CONFIG_PROTECT_MASK,
159         #
160
161         def f (conf):
162             mrgconf = re.sub(r'\._cfg', '._mrg', conf['new'])
163             archive = os.path.join(self.options['archive-dir'], conf['current'].lstrip('/'))
164             if self.options['use-rcs'] == 'yes':
165                 mrgfail = portage.dispatch_conf.rcs_archive(archive, conf['current'], conf['new'], mrgconf)
166             else:
167                 mrgfail = portage.dispatch_conf.file_archive(archive, conf['current'], conf['new'], mrgconf)
168             if os.path.exists(archive + '.dist'):
169                 unmodified = len(diff(conf['current'], archive + '.dist')[1]) == 0
170             else:
171                 unmodified = 0
172             if os.path.exists(mrgconf):
173                 if mrgfail or len(diff(conf['new'], mrgconf)[1]) == 0:
174                     os.unlink(mrgconf)
175                     newconf = conf['new']
176                 else:
177                     newconf = mrgconf
178             else:
179                 newconf = conf['new']
180
181             if newconf == mrgconf and \
182                 self.options.get('ignore-previously-merged') != 'yes' and \
183                 os.path.exists(archive+'.dist') and \
184                 len(diff(archive+'.dist', conf['new'])[1]) == 0:
185                 # The current update is identical to the archived .dist
186                 # version that has previously been merged.
187                 os.unlink(mrgconf)
188                 newconf = conf['new']
189
190             mystatus, myoutput = diff(conf['current'], newconf)
191             myoutput_len = len(myoutput)
192             same_file = 0 == myoutput_len
193             if mystatus >> 8 == 2:
194                 # Binary files differ
195                 same_cvs = False
196                 same_wsc = False
197             else:
198                 # Extract all the normal diff lines (ignore the headers).
199                 mylines = re.findall('^[+-][^\n+-].*$', myoutput, re.MULTILINE)
200
201                 # Filter out all the cvs headers
202                 cvs_header = re.compile('# [$]Header:')
203                 cvs_lines = list(filter(cvs_header.search, mylines))
204                 same_cvs = len(mylines) == len(cvs_lines)
205
206                 # Filter out comments and whitespace-only changes.
207                 # Note: be nice to also ignore lines that only differ in whitespace...
208                 wsc_lines = []
209                 for x in ['^[-+]\s*#', '^[-+]\s*$']:
210                    wsc_lines += list(filter(re.compile(x).match, mylines))
211                 same_wsc = len(mylines) == len(wsc_lines)
212
213             # Do options permit?
214             same_cvs = same_cvs and self.options['replace-cvs'] == 'yes'
215             same_wsc = same_wsc and self.options['replace-wscomments'] == 'yes'
216             unmodified = unmodified and self.options['replace-unmodified'] == 'yes'
217
218             if same_file:
219                 os.unlink (conf ['new'])
220                 self.post_process(conf['current'])
221                 if os.path.exists(mrgconf):
222                     os.unlink(mrgconf)
223                 return False
224             elif conf['current'] in frozen_files:
225                 """Frozen files are automatically zapped. The new config has
226                 already been archived with a .new suffix.  When zapped, it is
227                 left with the .new suffix (post_process is skipped), since it
228                 hasn't been merged into the current config."""
229                 auto_zapped.append(conf['current'])
230                 os.unlink(conf['new'])
231                 try:
232                     os.unlink(mrgconf)
233                 except OSError:
234                     pass
235                 return False
236             elif unmodified or same_cvs or same_wsc or \
237                 not protect_obj.isprotected(conf['current']):
238                 self.replace(newconf, conf['current'])
239                 self.post_process(conf['current'])
240                 if newconf == mrgconf:
241                     os.unlink(conf['new'])
242                 elif os.path.exists(mrgconf):
243                     os.unlink(mrgconf)
244                 return False
245             else:
246                 return True
247
248         confs = [x for x in confs if f(x)]
249
250         #
251         # Interactively process remaining
252         #
253
254         valid_input = "qhtnmlezu"
255
256         for conf in confs:
257             count = count + 1
258
259             newconf = conf['new']
260             mrgconf = re.sub(r'\._cfg', '._mrg', newconf)
261             if os.path.exists(mrgconf):
262                 newconf = mrgconf
263             show_new_diff = 0
264
265             while 1:
266                 clear_screen()
267                 if show_new_diff:
268                     cmd = self.options['diff'] % (conf['new'], mrgconf)
269                     cmd += pager
270                     spawn_shell(cmd)
271                     show_new_diff = 0
272                 else:
273                     cmd = self.options['diff'] % (conf['current'], newconf)
274                     cmd += pager
275                     spawn_shell(cmd)
276
277                 print()
278                 print('>> (%i of %i) -- %s' % (count, len(confs), conf ['current']))
279                 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=' ')
280
281                 # In some cases getch() will return some spurious characters
282                 # that do not represent valid input. If we don't validate the
283                 # input then the spurious characters can cause us to jump
284                 # back into the above "diff" command immediatly after the user
285                 # has exited it (which can be quite confusing and gives an
286                 # "out of control" feeling).
287                 while True:
288                     c = getch()
289                     if c in valid_input:
290                         sys.stdout.write('\n')
291                         sys.stdout.flush()
292                         break
293
294                 if c == 'q':
295                     sys.exit (0)
296                 if c == 'h':
297                     self.do_help ()
298                     continue
299                 elif c == 't':
300                     if newconf == mrgconf:
301                         newconf = conf['new']
302                     elif os.path.exists(mrgconf):
303                         newconf = mrgconf
304                     continue
305                 elif c == 'n':
306                     break
307                 elif c == 'm':
308                     merged = SCRATCH_DIR+"/"+os.path.basename(conf['current'])
309                     print()
310                     ret = os.system (self.options['merge'] % (merged, conf ['current'], newconf))
311                     ret = os.WEXITSTATUS(ret)
312                     if ret < 2:
313                         ret = 0
314                     if ret:
315                         print("Failure running 'merge' command")
316                         continue
317                     shutil.copyfile(merged, mrgconf)
318                     os.remove(merged)
319                     mystat = os.lstat(conf['new'])
320                     os.chmod(mrgconf, mystat[ST_MODE])
321                     os.chown(mrgconf, mystat[ST_UID], mystat[ST_GID])
322                     newconf = mrgconf
323                     continue
324                 elif c == 'l':
325                     show_new_diff = 1
326                     continue
327                 elif c == 'e':
328                     if 'EDITOR' not in os.environ:
329                         os.environ['EDITOR']='nano'
330                     os.system(os.environ['EDITOR'] + ' ' + newconf)
331                     continue
332                 elif c == 'z':
333                     os.unlink(conf['new'])
334                     if os.path.exists(mrgconf):
335                         os.unlink(mrgconf)
336                     break
337                 elif c == 'u':
338                     self.replace(newconf, conf ['current'])
339                     self.post_process(conf['current'])
340                     if newconf == mrgconf:
341                         os.unlink(conf['new'])
342                     elif os.path.exists(mrgconf):
343                         os.unlink(mrgconf)
344                     break
345                 else:
346                     raise AssertionError("Invalid Input: %s" % c)
347
348         if auto_zapped:
349             print()
350             print(" One or more updates are frozen and have been automatically zapped:")
351             print()
352             for frozen in auto_zapped:
353                 print("  * '%s'" % frozen)
354             print()
355
356     def replace (self, newconf, curconf):
357         """Replace current config with the new/merged version.  Also logs
358         the diff of what changed into the configured log file."""
359         os.system((DIFF_CONTENTS % (curconf, newconf)) + '>>' + self.options["log-file"])
360         try:
361             os.rename(newconf, curconf)
362         except (IOError, os.error) as why:
363             print('dispatch-conf: Error renaming %s to %s: %s; fatal' % \
364                   (newconf, curconf, str(why)), file=sys.stderr)
365
366
367     def post_process(self, curconf):
368         archive = os.path.join(self.options['archive-dir'], curconf.lstrip('/'))
369         if self.options['use-rcs'] == 'yes':
370             portage.dispatch_conf.rcs_archive_post_process(archive)
371         else:
372             portage.dispatch_conf.file_archive_post_process(archive)
373
374
375     def massage (self, newconfigs):
376         """Sort, rstrip, remove old versions, break into triad hash.
377
378         Triad is dictionary of current (/etc/make.conf), new (/etc/._cfg0003_make.conf)
379         and dir (/etc).
380
381         We keep ._cfg0002_conf over ._cfg0001_conf and ._cfg0000_conf.
382         """
383         h = {}
384         configs = []
385         newconfigs.sort ()
386
387         for nconf in newconfigs:
388             nconf = nconf.rstrip ()
389             conf  = re.sub (r'\._cfg\d+_', '', nconf)
390             dirname   = os.path.dirname(nconf)
391             conf_map  = {
392                 'current' : conf,
393                 'dir'     : dirname,
394                 'new'     : nconf,
395             }
396
397             if conf in h:
398                 mrgconf = re.sub(r'\._cfg', '._mrg', h[conf]['new'])
399                 if os.path.exists(mrgconf):
400                     os.unlink(mrgconf)
401                 os.unlink(h[conf]['new'])
402                 h[conf].update(conf_map)
403             else:
404                 h[conf] = conf_map
405                 configs.append(conf_map)
406
407         return configs
408
409
410     def do_help (self):
411         print(); print
412
413         print('  u -- update current config with new config and continue')
414         print('  z -- zap (delete) new config and continue')
415         print('  n -- skip to next config, leave all intact')
416         print('  e -- edit new config')
417         print('  m -- interactively merge current and new configs')
418         print('  l -- look at diff between pre-merged and merged configs')
419         print('  t -- toggle new config between merged and pre-merged state')
420         print('  h -- this screen')
421         print('  q -- quit')
422
423         print(); print('press any key to return to diff...', end=' ')
424
425         getch ()
426
427
428 def getch ():
429     # from ASPN - Danny Yoo
430     #
431     import sys, tty, termios
432
433     fd = sys.stdin.fileno()
434     old_settings = termios.tcgetattr(fd)
435     try:
436         tty.setraw(sys.stdin.fileno())
437         ch = sys.stdin.read(1)
438     finally:
439         termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
440     return ch
441
442 def clear_screen():
443     try:
444         import curses
445         try:
446             curses.setupterm()
447             sys.stdout.write(_unicode_decode(curses.tigetstr("clear")))
448             sys.stdout.flush()
449             return
450         except curses.error:
451             pass
452     except ImportError:
453         pass
454     os.system("clear 2>/dev/null")
455
456 from portage.process import find_binary, spawn
457 shell = os.environ.get("SHELL")
458 if not shell or not os.access(shell, os.EX_OK):
459     shell = find_binary("sh")
460
461 def spawn_shell(cmd):
462     if shell:
463         sys.__stdout__.flush()
464         sys.__stderr__.flush()
465         spawn([shell, "-c", cmd], env=os.environ,
466             fd_pipes = {  0 : portage._get_stdin().fileno(),
467                           1 : sys.__stdout__.fileno(),
468                           2 : sys.__stderr__.fileno()})
469     else:
470         os.system(cmd)
471
472 def usage(argv):
473     print('dispatch-conf: sane configuration file update\n')
474     print('Usage: dispatch-conf [config dirs]\n')
475     print('See the dispatch-conf(1) man page for more details')
476     sys.exit(os.EX_OK)
477
478 for x in sys.argv:
479     if x in ('-h', '--help'):
480         usage(sys.argv)
481     elif x in ('--version'):
482         print("Portage", portage.VERSION)
483         sys.exit(os.EX_OK)
484
485 # run
486 d = dispatch ()
487
488 if len(sys.argv) > 1:
489     # for testing
490     d.grind(sys.argv[1:])
491 else:
492     d.grind(portage.util.shlex_split(
493         portage.settings.get('CONFIG_PROTECT', '')))